· Paul Lukic · 3 分鐘閱讀 · securityaudit-logai-coding-agentsincident-responseobservability

每工作階段稽核日誌:限定 AI 編程代理的影響半徑

用一份持續滾動的稽核日誌記下 AI 編程代理執行的每條 shell 命令是好事。每工作階段一份更好。本文說明為什麼作用域比體量更重要、本週 Coograph 改了什麼,以及如何把同樣的模式套到你自己的環境。

本文目錄

兩天前我們發了一篇關於 TanStack npm 投毒事件的文章,主張每個運行 AI 編程代理的團隊都應該把代理執行的每條 shell 命令都寫入一份本地、僅追加的日誌。我們是認真的。每個 Coograph 安裝裡都帶一份。

然後有讀者問出了擊穿我們自己設計的問題:「那個檔案是所有工作階段共用一份,還是每工作階段一份?」

是所有工作階段共用一份。比沒有強 — 但也只強一點點。本文講我們改了什麼、為什麼在事件回應中作用域比體量更重要,以及不論你用不用 Coograph 都能套用同一種模式。

對比圖:單一稽核日誌檔案中各工作階段交錯混雜 vs. 兩層結構(全域尾部 + 每工作階段檔案),凸顯每工作階段檔案能一眼隔離出單一工作階段的可疑命令

「夠用」的稽核日誌到底要做什麼

shell 命令稽核日誌的工作不是記帳。工作是 在壓力下快速回答帶作用域的問題。三週後你讀到一份安全公告。你依賴的某個套件在四十分鐘的窗口內是惡意的。你要回答一個問題:

「我的 AI 代理在那四十分鐘內有沒有執行過任何拉取了那個套件的命令?」

要回答這個,日誌必須同時支援三件事:

  1. 時間作用域。 公告給你一個窗口。你得 grep 那個窗口。
  2. 工作階段作用域。 「代理在我上週除錯那個 PR 的對話裡做了什麼」 — 這個問題代理自己答不了,因為它的對話歷史是摘要、是片段,而且存在別人的伺服器上。
  3. 命令作用域。 「把每條 npm install 都列出來。」 只要檔案格式一致就輕而易舉。

單一僅追加日誌能搞定時間作用域(按時間戳排序、切片窗口)。能搞定命令作用域(grep 模式)。但基本搞不定工作階段作用域,因為工作階段會交錯 — 兩個並行的代理工作階段在不同終端裡寫進同一個檔案,中間沒有分隔。如果你知道開始時間還可以重建工作階段,但凌晨 2 點處理事件時,你不想做任何重建。

每工作階段日誌三件全占,因為工作階段邊界是一等公民。

本週 Coograph 改了什麼

本週之前,Coograph 鉤子寫一個檔案:.claude/session.log。每個 Claude Code 工作階段的每條 Bash 命令,按順序、帶時間戳,僅以換行分隔。一個代理。一個桶。沒有作用域。

新鉤子一次性帶來三個改動:

1. 每工作階段拆分。 單一全域檔案變成兩層:

檔案每行內容何時讀
.coograph/session.log[2026-05-17 09:42:18] [claude-code] [4f1c8b3e] npm install跨每個代理 + 每個工作階段的單一時序尾部。每行前綴工具名和短工作階段 id。最適合 跨工作階段跨工具 grep。
.coograph/sessions/<session_id>.log[2026-05-17 09:42:18] [claude-code] npm install每個代理工作階段一個檔案。無工作階段 id 前綴,因為檔名已經帶了。最適合 單一工作階段 問題。

2. 統一路徑。 日誌從 .claude/session.log 搬到 .coograph/session.log。原因是第三個改動。

3. 多工具支援。 同一份稽核軌跡現在覆蓋 Claude Code、Codex CLIOpenCode。三者都寫入相同的 .coograph/ 檔案,每行以代理名前綴(claude-codecodex-cliopencode)。一次 grep、一個真相、三個代理。

Coograph 支援的另外五個工具 — VS Code Copilot、Cursor、Windsurf、Aider、Cline — 截至 2026 年 5 月在任何公開 API 中都沒有暴露 pre-tool-use 鉤子面,所以我們無法用同樣的方式攔截它們的 shell 命令。README 為每個工具記錄了回退方案(shell 層 trap DEBUG、VS Code 命令歷史等)。當上游工具發布鉤子 API 時,我們會接上。

所有 .coograph/ 檔案都被 gitignore、按專案、僅追加、本地優先。同一個鉤子一次寫兩個檔案 — 沒有額外成本。工作階段 id 來自代理負載(Claude Code 每個對話工作階段傳一個 UUID;Codex CLI 傳同樣的欄位;OpenCode 暴露 sessionID)。如果某個代理的負載沒帶工作階段 id,相應行會落到名為 unknown.log 的檔案 — 仍然記錄,只是沒分開。

Python 鉤子六十行。OpenCode 外掛用 TypeScript 寫也差不多。三者都放在 github.com/paullukic/coograph.claude/hooks/log-bash.py.codex/hooks/log-bash.py.opencode/plugin/log-bash.ts。MIT 授權。

示意圖:三個代理 — Claude Code、Codex CLI、OpenCode — 各自帶一個 PreToolUse 鉤子腳本,全部寫入專案根下相同的 .coograph/session.log 全域尾部和每工作階段日誌檔案

為什麼工作階段邊界比檔案體量更重要

我們本可以靠寫一份更密的全域日誌來解決工作階段作用域問題 — 在大檔案的每行裡嵌入工作階段 id,按工作階段 id grep 來限定作用域。多數單體日誌系統都是這麼幹的,而且能跑通。

我們選了兩個檔案,因為事件回應是人來做的,不是腳本化的。凌晨 2 點你正讀一份新公告,你不會想跑一條 grep | awk | sort 管道來抽取一段對話。你想敲的是:

cat .coograph/sessions/<that-one-session>.log

…然後螢幕上出現一份自包含的紀錄,只含那段對話的命令,按順序,沒有多餘雜訊。這種人體工學的代價是多一個目錄、每條 Bash 命令多一次 open()。我們認這筆帳。

全域日誌依然存在,因為有些問題天然是跨工作階段的:

  • 「本季全部工作裡,代理曾在哪些時刻碰過 /etc/?」
  • 「這台筆電上所有 Coograph 專案裡,曾經有過 curl | sh 嗎?」
  • 「過去一個月裡,代理跑得最頻繁的命令是什麼?」

這些問題靠帶工作階段 id 前綴的單一尾部就對了。兩個檔案回答兩種形態的問題。兩個都留下是最便宜的保險。

你應該能用一條終端命令做的四種操作

我們做個小斷言。如果你的稽核日誌不能讓你用一條終端命令完成下面四件事,它就沒有做好它的活:

# 1. 限定到一個工作階段 — 該對話的完整歷史(任何代理)
cat .coograph/sessions/4f1c8b3e-a2d1-4f9c-8e7a-2b3c5d6e7f8a.log

# 2. 限定到一個時間窗口 — 已知事件窗口內跑過什麼
awk -F'] ' '$1 >= "[2026-05-11 19:20:00" && $1 <= "[2026-05-11 19:26:00"' .coograph/session.log

# 3. 限定到一個命令模式 — 所有代理 + 所有工作階段中的每次安裝
grep -E '^\[.*\] \[(claude-code|codex-cli|opencode)\] \[.*\] (npm|pnpm|yarn) install' .coograph/session.log

# 4. 交叉引用 — 哪些工作階段跑過可疑命令,按頻次排序
grep "curl.*| *sh" .coograph/session.log | grep -oE '\[[a-f0-9]{8}\]' | sort | uniq -c | sort -rn

如果你目前的方案要三個工具加一個資料庫才能回答這些問題,那是過度設計。如果它根本答不了,那是設計不足。正確答案是純文字、兩個檔案、標準 Unix 工具集。

自己動手 — 哪怕你不用 Coograph

我們沒說你必須用 Coograph 才能做這件事。我們說的是你需要 某個 東西來做。如果你已經在用 Claude Code 或 Codex CLI,鉤子就二十行 Python:

#!/usr/bin/env python3
"""log-bash: append every Bash command to a global tail and a per-session file."""
import json, os, sys
from datetime import datetime
from pathlib import Path

AGENT = "claude-code"  # or "codex-cli" — set this per hook file

payload = json.loads(sys.stdin.read() or "{}")
if payload.get("tool_name") == "Bash":
    cmd = (payload.get("tool_input") or {}).get("command", "")
    if cmd:
        sid = "".join(c for c in str(payload.get("session_id") or "unknown")
                      if c.isalnum() or c in "-_")[:64] or "unknown"
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        root = Path(payload.get("cwd") or os.getcwd()) / ".coograph"
        (root / "sessions").mkdir(parents=True, exist_ok=True)
        (root / "session.log").open("a", encoding="utf-8").write(
            f"[{ts}] [{AGENT}] [{sid[:8]}] {cmd}\n")
        (root / "sessions" / f"{sid}.log").open("a", encoding="utf-8").write(
            f"[{ts}] [{AGENT}] {cmd}\n")

sys.exit(0)

Claude Code 的接法:丟到 .claude/hooks/log-bash.py,標記可執行,在 .claude/settings.json 裡按 Bash 工具過濾接成 PreToolUse 鉤子。把 .coograph/ 加進 .gitignore。搞定。

Codex CLI 的接法:丟到 .codex/hooks/log-bash.py,然後在 ~/.codex/config.toml 裡加一次以下內容 — $(git rev-parse --show-toplevel) 這層間接讓一條全域設定在每個 Coograph 初始化過的專案裡都生效:

[[hooks.PreToolUse]]
matcher = "^Bash$"

[[hooks.PreToolUse.hooks]]
type = "command"
command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/log-bash.py"'
timeout = 5

OpenCode 的接法:同樣的思路,用 TypeScript 借 tool.execute.before 外掛事件。完整外掛在 Coograph 儲存庫的 .opencode/plugin/log-bash.ts — 不到五十行。

VS Code Copilot、Cursor、Windsurf、Aider、Cline — 截至 2026 年 5 月沒有一個在公開 API 裡暴露 pre-tool-use 鉤子面。沒有乾淨的攔截點。回退方案:VS Code 內建命令歷史(Copilot)、整合終端回捲(Cursor / Windsurf / Cline),或 shell 層埋點(trap DEBUGPROMPT_COMMAND、Linux 上的 auditd)。Coograph 的 README 為每個工具記錄了取捨。上游發布鉤子 API 時,我們會接上。

這套依然給不了你什麼

稽核日誌不是偵測。不是防禦。不是即時攔截。

它是為安全事件準備的最便宜的 事後作用域物證。「我們被搞了,不知道代理幹了啥,所有憑證全部輪換」 和 「我們被搞了,這是那個工作階段,這是關鍵的四條命令,輪換這六個憑證然後翻篇」 — 這之間的差距,是一週事件回應和一個下午的差距。

下一份供應鏈公告砸下來時 — 一定會 — 你不想成為那個跑 grep -r~/.claude_logs/ 然後祈禱自己在某處設過全域保留政策的團隊。你想把檔案路徑背下來。每台機器都有。每個專案都有。

每工作階段。僅追加。Gitignored。本地優先。搞定。

如何取得新行為

如果你的專案裡已經有 Coograph,在 AI 工具的對話裡重新執行初始化器同步最新鉤子(Claude Code、Cursor、Copilot、OpenCode、Windsurf、Aider、Cline 用 /coograph-init;Codex CLI 用 $coograph-init)。初始化器會覆寫鉤子腳本,在已有 Claude 變體旁邊放下 .codex/hooks/log-bash.py.opencode/plugin/log-bash.ts,並更新 .gitignore 以涵蓋 .coograph/。已有的 .claude/session.log 歷史會保留 — mv .claude/session.log .coograph/session.log.legacy 讓它繼續可掃描。

如果你還沒有 Coograph:

# from your project root
git clone https://github.com/paullukic/coograph.git ../coograph

…然後在你的 AI 工具裡呼叫初始化器。兩分鐘。約四十行 Python 落到你的儲存庫。下一條 Bash 命令開始日誌就開始捕獲。

完整步驟見 coograph.com/docs/getting-started/

如果再給我們兩週會做什麼

如果你在意,兩個後續我們會做:

  • 一個 coograph audit CLI,接受一個工作階段 id 或時間窗口,列印乾淨的報告 — 把上面四種操作做成命名子命令,這樣你不用記 awk 語法。可能四十行 shell。
  • 寫入時的選擇性遮罩。 目前日誌會捕獲完整命令,包括可能敏感的環境變數或參數(例如 AWS_SECRET=... aws s3 cp ...)。在鉤子時點加一道小遮罩,按常見密鑰模式在寫入前打碼。日誌可能要分享給第三方時有用。

兩個都還沒裝在盒子裡。想要哪個,開一個 issue

更簡單的版本現在就發。這才是關鍵版本。

削減你的 AI 編程帳單 30–80%。Coograph 採用 MIT 授權、永久免費。Pro 提供客製服務。