Vulnerability
run_agent_loop's main turn loop calls run_turn(...).await? (crates/zeph-subagent/src/agent_loop.rs:899-924). The ? propagates any Err from run_turn (whose only fallible operation is the LLM call itself, e.g. call_provider_with_status(...).await?) as an early return from the enclosing function — before reaching either publish_completed_status or the anchor-finalize block that follows the loop (agent_loop.rs:949-956, writer.finalize().await).
An LLM call failing (timeout, rate limit, transient network error, provider outage) is a common, non-exotic condition in production. If this happens on any turn after at least one prior turn has already appended chained transcript entries, the transcript is left chained but never anchored.
Per the module's own documented threat model (transcript.rs:650-714), a whole-file chain-strip (removing every chain field from a transcript) on an unanchored transcript is indistinguishable from legitimate pre-upgrade legacy content and is auto-trusted — exactly the downgrade attack the vault-anchor feature (#6449, landed via #6461) was built to close.
Note: TurnOutcome::Cancelled correctly breaks out of the loop (not an early return), so cancellation still reaches finalize(). Only the LLM-error path skips it.
Severity
High — a common, attacker-triggerable-adjacent failure mode (a rate-limited or timed-out LLM call, which a hostile counterpart in a multi-agent conversation could deliberately induce via oversized input) leaves the transcript's tamper-evidence in a state the system's own threat model calls out as exploitable.
Location
crates/zeph-subagent/src/agent_loop.rs:899-924 (early return via ? skips everything after the loop)
crates/zeph-subagent/src/agent_loop.rs:949-956 (the skipped finalize() call)
crates/zeph-subagent/src/transcript.rs:680-714 (confirms a whole-strip-with-no-anchor is treated as benign legacy content)
Attack Scenario
A sub-agent's turn loop is running a multi-turn task. Partway through (after several turns have already been chained), the LLM provider times out or rate-limits the request — a condition that can occur naturally, or could be deliberately induced by an adversarial party who controls upstream content that inflates token usage or triggers provider-side rate limiting. run_turn returns Err, the ? short-circuits the loop, and the transcript file is left with a valid hash-chain but no anchor commitment. Later, an attacker with filesystem write access (e.g. compromised host, malicious co-tenant) strips every chain field from the file. Per the documented verification logic, this now reads as a legitimate pre-anchor-feature legacy transcript rather than a tampered one — defeating the anchor mechanism's entire purpose for this file.
Remediation
Ensure finalize() runs on every exit path from the turn loop, not just the two currently reached (normal loop-exhaustion and explicit cancellation). Wrap the loop body (or the whole function) so the anchor-finalize step runs in a finally-equivalent (e.g. via a scope guard, or restructure to catch run_turn's Err, still finalize, then re-propagate the original error).
References
#6449 (vault-anchor feature), #6461 (vault-anchor downgrade resistance implementation), #6453 (prior transcript/durable tamper-evidence hardening)
Vulnerability
run_agent_loop's main turn loop callsrun_turn(...).await?(crates/zeph-subagent/src/agent_loop.rs:899-924). The?propagates anyErrfromrun_turn(whose only fallible operation is the LLM call itself, e.g.call_provider_with_status(...).await?) as an earlyreturnfrom the enclosing function — before reaching eitherpublish_completed_statusor the anchor-finalize block that follows the loop (agent_loop.rs:949-956,writer.finalize().await).An LLM call failing (timeout, rate limit, transient network error, provider outage) is a common, non-exotic condition in production. If this happens on any turn after at least one prior turn has already appended chained transcript entries, the transcript is left chained but never anchored.
Per the module's own documented threat model (
transcript.rs:650-714), a whole-file chain-strip (removing everychainfield from a transcript) on an unanchored transcript is indistinguishable from legitimate pre-upgrade legacy content and is auto-trusted — exactly the downgrade attack the vault-anchor feature (#6449, landed via #6461) was built to close.Note:
TurnOutcome::Cancelledcorrectlybreaks out of the loop (not an early return), so cancellation still reachesfinalize(). Only the LLM-error path skips it.Severity
High — a common, attacker-triggerable-adjacent failure mode (a rate-limited or timed-out LLM call, which a hostile counterpart in a multi-agent conversation could deliberately induce via oversized input) leaves the transcript's tamper-evidence in a state the system's own threat model calls out as exploitable.
Location
crates/zeph-subagent/src/agent_loop.rs:899-924(early return via?skips everything after the loop)crates/zeph-subagent/src/agent_loop.rs:949-956(the skippedfinalize()call)crates/zeph-subagent/src/transcript.rs:680-714(confirms a whole-strip-with-no-anchor is treated as benign legacy content)Attack Scenario
A sub-agent's turn loop is running a multi-turn task. Partway through (after several turns have already been chained), the LLM provider times out or rate-limits the request — a condition that can occur naturally, or could be deliberately induced by an adversarial party who controls upstream content that inflates token usage or triggers provider-side rate limiting.
run_turnreturnsErr, the?short-circuits the loop, and the transcript file is left with a valid hash-chain but no anchor commitment. Later, an attacker with filesystem write access (e.g. compromised host, malicious co-tenant) strips everychainfield from the file. Per the documented verification logic, this now reads as a legitimate pre-anchor-feature legacy transcript rather than a tampered one — defeating the anchor mechanism's entire purpose for this file.Remediation
Ensure
finalize()runs on every exit path from the turn loop, not just the two currently reached (normal loop-exhaustion and explicit cancellation). Wrap the loop body (or the whole function) so the anchor-finalize step runs in afinally-equivalent (e.g. via a scope guard, or restructure to catchrun_turn'sErr, still finalize, then re-propagate the original error).References
#6449 (vault-anchor feature), #6461 (vault-anchor downgrade resistance implementation), #6453 (prior transcript/durable tamper-evidence hardening)