日志证据绑定 深度解析
日志证据绑定 模块拆解:从真实源码行号看 agentacct 如何把本地证据、隐私边界和控制面分开。
导入链与契约
这个子页面聚焦 log evidence。src/agent_chronicle/log_evidence.py:54 到 src/agent_chronicle/log_evidence.py:69 固定 creation tool allowlist,src/agent_chronicle/log_evidence.py:113 到 src/agent_chronicle/log_evidence.py:138 严格解析单个 created event payload。这样做的意义是让 read tool、错误文本、截断 JSON 和外部 server 名称都只能被计数为拒绝,而不是悄悄变成上下文证据。 tests/test_log_evidenced_linking.py:188,这里再看 tests/test_log_evidenced_linking.py:222,这里再看 tests/test_log_evidenced_linking.py:246
54# The ONLY tools whose responses may donate an evidenced event id. Every one 55# of them returns exactly one created event under a top-level "event" key 56# (mcp.py); read tools (sentinel_list_events, sentinel_get_event_summary, 57# sentinel_list_runs, sentinel_get_report, ...) echo foreign ids and must 58# never appear here. 59# frozen: the sentinel_* tool names survive the Agent Chronicle rename — 60# historical stores/logs/files carry them forever, and three shipped 61# instruction surfaces plus real user CLAUDE.md/AGENTS.md files quote them. 62SENTINEL_CREATION_TOOLS = frozenset( 63 { 64 "sentinel_record_event", 65 "sentinel_attach_client_context", 66 "sentinel_record_section", 67 "sentinel_record_agent_usage_debug", 68 "sentinel_record_machine_check", 69 } 70) 71 72# \Z (not $) so a trailing newline is rejected: 'evt_deadbeef\n' must NOT 73# pass at any of the .match() call sites ($ matches before a final newline). 74EVIDENCED_EVENT_ID_RE = re.compile(r"^evt_[0-9a-f]{6,64}\Z") 75 76# Real max observed in one session: 157 distinct created ids. The cap bounds 77# hostile/huge sessions; the true total is always preserved alongside so 78# overflow is visible, never silent. 79EVIDENCED_EVENT_IDS_CAP = 200 80 81# Codex fork rollouts can begin by replaying the parent's transcript, including 82# old MCP creation responses. A small clock-skew allowance keeps near-boundary 83# facts joinable while still rejecting the impossible case: an event recorded 84# well before the alleged donor session existed. 85CODEX_REPLAY_CLOCK_SKEW_SECONDS = 5.0 86CODEX_REPLAY_REJECTION_REASON = "codex_event_before_session_start" 87CODEX_REPLAY_TIME_UNPROVEN_REASON = "codex_replay_timestamps_unproven" 88SOURCE_NAMESPACE_CONFLICT_REASON = "source_namespace_conflict" 89CROSS_KIND_DONOR_IDENTITY_CONFLICT_REASON = "cross_kind_donor_identity_conflict" 90CANONICAL_EVENT_IDENTITY_CONFLICT_REASON = "canonical_event_identity_conflict" 91 92# The documented MCP registration names (every current setup path writes 93# "agentacct"). Claude Code transcripts carry the registration key verbatim in 94# `mcp__<server>__<tool>`; Codex normalizes the config key to underscores in the 95# rollout `namespace` field (`mcp__agent_chronicle` / `mcp__agent_sentinel`; the 96# hyphen-free `agentacct` normalizes to itself). A custom registration name 97# loses evidence links (skipped-but-counted, never guessed) — documented in 98# INSTALL.md. 99# 100# ALL THREE names are accepted FOREVER: `agentacct` is what new installs now 101# register, and the pre-rename `agent-chronicle` / `agent-sentinel` keys stay 102# accepted because historical client logs carry them forever and pre-rename 103# registrations keep producing them in new sessions — dropping an old key would 104# silently unlink every pre-rename session on the next import. 105SENTINEL_SERVER_KEY = "agentacct" # the name new installs register 106ACCEPTED_SERVER_KEYS = frozenset({"agentacct", "agent-chronicle", "agent-sentinel"}) 107_CODEX_ACCEPTED_NAMESPACES = frozenset( 108 {"agentacct", "agent-chronicle", "agent_chronicle", "agent-sentinel", "agent_sentinel"} 109) 110_CLAUDE_TOOL_PREFIX = "mcp__" 111 112 113def extract_created_event_id(text: str) -> str | None: 114 """Strict single-created-event shape check shared by every wire adapter. 115 116 Accepts exactly the creation-tool payload shape: a JSON dict with a 117 top-level ``"event"`` dict whose ``event_id`` is a well-formed evt id. 118 Rejects (belt-and-braces — the tool-name allowlist already excludes read 119 tools): list-shaped payloads carrying ``"events"``/``"runs"`` lists, 120 error texts, truncated/torn JSON, outcome-only machine_check payloads, 121 and any non-dict shape. 122 """ 123 124 try: 125 payload = json.loads(text) 126 except (json.JSONDecodeError, ValueError): 127 return None 128 if not isinstance(payload, dict): 129 return None 130 event = payload.get("event") 131 if not isinstance(event, dict): 132 return None 133 if any(isinstance(payload.get(key), list) for key in ("events", "runs")): 134 return None 135 event_id = event.get("event_id") 136 if not isinstance(event_id, str) or not EVIDENCED_EVENT_ID_RE.match(event_id): 137 return None 138 return event_id
核心实现,逐段拆
▸允许哪些工具捐赠 event id
src/agent_chronicle/log_evidence.py:141 到 src/agent_chronicle/log_evidence.py:205 展示第一层实现。这里不是抽象概念,而是把边界条件写成代码路径:调用方只能沿这些函数和常量进入,异常和漂移被计数或拒绝。 src/agent_chronicle/log_evidence.py:144,这里再看 src/agent_chronicle/log_evidence.py:150,这里再看 src/agent_chronicle/log_evidence.py:203
141def codex_namespace_matches_sentinel(namespace: Any) -> bool: 142 """True when a codex function_call namespace identifies the sentinel server. 143 144 Absent namespace is accepted (older codex versions may omit it; the bare 145 tool-name allowlist still gates). A present namespace must be exactly one 146 of the pinned server keys (new or pre-rename) modulo codex's underscore 147 normalization and the optional ``mcp__`` prefix — substring matches 148 (``mcp__not_agent_chronicle_fake``) are rejected. 149 """ 150 151 if namespace is None: 152 return True 153 if not isinstance(namespace, str): 154 return False 155 remainder = namespace.removeprefix(_CLAUDE_TOOL_PREFIX) 156 return remainder in _CODEX_ACCEPTED_NAMESPACES 157 158 159def classify_codex_function_call(name: Any, namespace: Any) -> str | None: 160 """"accepted" / "rejected" / None for a codex rollout function_call. 161 162 "rejected" means the bare tool name is a creation tool but the namespace 163 does not identify the sentinel server — its output is skipped-but-counted 164 so custom registration names and wire drift surface in the counters. 165 """ 166 167 if not isinstance(name, str) or name not in SENTINEL_CREATION_TOOLS: 168 return None 169 return "accepted" if codex_namespace_matches_sentinel(namespace) else "rejected" 170 171 172def classify_claude_tool_use(name: Any) -> str | None: 173 """"accepted" / "rejected" / None for a Claude Code tool_use block name. 174 175 Accepted only for ``mcp__agent-chronicle__<creation tool>`` or the 176 pre-rename ``mcp__agent-sentinel__<creation tool>`` exactly; a 177 creation-tool name under any other server segment is rejected 178 (skipped-but-counted). Non-MCP and non-creation names return None. 179 """ 180 181 if not isinstance(name, str) or not name.startswith(_CLAUDE_TOOL_PREFIX): 182 return None 183 server, separator, tool = name.removeprefix(_CLAUDE_TOOL_PREFIX).partition("__") 184 if not separator or tool not in SENTINEL_CREATION_TOOLS: 185 return None 186 return "accepted" if server in ACCEPTED_SERVER_KEYS else "rejected" 187 188 189def classify_codex_mcp_invocation(server: Any, tool: Any) -> str | None: 190 """"accepted" / "rejected" / None for a codex mcp_tool_call_end invocation. 191 192 Codex 0.144.0-alpha.4 dropped the duplicate ``function_call`` records for 193 MCP tools; the ``mcp_tool_call_end`` event_msg's ``invocation`` block 194 (``server``/``tool``) is now the only channel. Same allowlist-first rule 195 as :func:`classify_codex_function_call`, with ONE deliberate difference: 196 the ``server`` field is always present in this shape, so a None/absent 197 server is REJECTED (skipped-but-counted — absence is drift), never 198 accepted the way an absent function_call namespace is. 199 """ 200 201 if not isinstance(tool, str) or tool not in SENTINEL_CREATION_TOOLS: 202 return None 203 if server is None: 204 return "rejected" 205 return "accepted" if codex_namespace_matches_sentinel(server) else "rejected"
▸Codex/Claude namespace 形态如何 fail closed
src/agent_chronicle/log_evidence.py:208 到 src/agent_chronicle/log_evidence.py:240 是第二层实现,重点在失败路径和原子性。页面关注的是这些分支如何避免把未验证输入提升为 authority。 src/agent_chronicle/log_evidence.py:212,这里再看 src/agent_chronicle/log_evidence.py:220,这里再看 src/agent_chronicle/log_evidence.py:237
208def unwrap_codex_mcp_result(result: Any) -> str | None: 209 """Codex mcp_tool_call_end ``result`` field -> the MCP text-block string. 210 211 Observed wire shape: ``{"Ok": {"content": [{"type": "text", "text": 212 "<server JSON payload>"}]}}``. ``Err`` results and any other shape return 213 None (skipped-but-counted upstream — a failed tool call created nothing). 214 """ 215 216 if not isinstance(result, dict): 217 return None 218 ok = result.get("Ok") 219 if not isinstance(ok, dict): 220 return None 221 content = ok.get("content") 222 if not isinstance(content, list): 223 return None 224 return _first_text_block(content) 225 226 227def unwrap_codex_output_text(output: Any) -> str | None: 228 """Codex function_call_output ``output`` field -> the MCP text-block string. 229 230 Observed wire shape: a string holding a human wrapper (``Wall time: ...\n 231 Output:\n``) followed by a JSON array of MCP content blocks whose single 232 text block carries the server's JSON payload. Bare-JSON strings and dict 233 outputs with a ``content`` block list (future codex shapes) are also 234 handled. Returns None for anything else (skipped-but-counted upstream). 235 """ 236 237 if isinstance(output, dict): 238 content = output.get("content") 239 if isinstance(content, list): 240 return _first_text_block(content)
关键流程
▸实现判断
证据链的关键是先按工具名和 server namespace 收窄,再解析输出形状;如果 call_id 对不上、server key 不在接受集合、event_id 形状不合法,结果都不会进入 task/context join。 这条路径还可以和主页面的 src/agent_chronicle/source_discovery.py:37、src/agent_chronicle/capture_runtime.py:41、src/agent_chronicle/control_web.py:50 连起来看:agentacct 的策略是收集证据但不收集敏感正文,展示状态但不泄露执行权柄。
边界 / 失败模式
src/agent_chronicle/log_evidence.py:54附近的入口如果收到不可信输入,只能产出降级或拒绝,不得写入 raw prompt/response。src/agent_chronicle/log_evidence.py:141的分支必须和测试夹具保持同步,避免客户端格式漂移后静默误归因。src/agent_chronicle/log_evidence.py:240附近的失败路径需要持续保留计数或错误码,方便 dashboard 暴露健康状态。