agentacctacct
§ · Nested detail page

日志证据绑定 深度解析

日志证据绑定 模块拆解:从真实源码行号看 agentacct 如何把本地证据、隐私边界和控制面分开。

§ 00 · IMPORT CHAIN

导入链与契约

这个子页面聚焦 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

src/agent_chronicle/log_evidence.py · L54-138Python
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
§ 01 · CORE IMPL

核心实现,逐段拆

▸允许哪些工具捐赠 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

src/agent_chronicle/log_evidence.py · L141-205Python
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

src/agent_chronicle/log_evidence.py · L208-240Python
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)
§ 02 · KEY FLOW

关键流程

Codex rollout / Claude transcript ─▶ allowlisted creation call ─▶ paired output text ─▶ evt_* candidate ─▶ conflict-aware matcher

▸实现判断

证据链的关键是先按工具名和 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 的策略是收集证据但不收集敏感正文,展示状态但不泄露执行权柄。

§ 03 · EDGE CASES

边界 / 失败模式

  • 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 暴露健康状态。
Sub-page · 2026-07-27 Source · mikehasa/agentacct ↑ agentacct 主页面