fix(hooks): pass failed commands through verbatim, never distill a non-zero exit into success#121
Merged
Merged
Conversation
…n-zero exit into success (#120) OMNI's normalize layer parsed each agent's failure signal and then discarded it: Codex `exit_code` was never read into CodexInput, Pi `isError` was `#[allow(dead_code)]`, and MCP `result.isError` was documented in a comment but never deserialized. So a command that failed still ran the full distiller, and a failed command's output could be summarised into something that reads as success — the worst outcome, because a fabricated success terminates investigation while a fabricated error only costs a retry. Fix, at the single point where format knowledge already lives: - NormalizedInput gains `failed`, set from each agent's own signal (Codex exit_code != 0, Pi isError, MCP result.isError). - One guard in post_tool::process_payload: `if normalized.failed { return None }` — passthrough, host keeps the raw bytes at zero marker cost. Claude Code needs no code change: it sends a failed command as a bare `tool_response` string ("Error: Exit code N…") that never matches ClaudeToolResponse, so parsing already bails to None (passthrough). A regression test locks that in so a future, more-lenient parser can't silently reintroduce the fabrication. Verified end-to-end through the real binary: a failed `docker build` (exit_code 1) that previously distilled 9207→6090 B now passes through (0 B emitted, host keeps raw); the same output with exit_code 0 still distills to 6090 B, so savings on successful commands are untouched. Teeth proven by disabling the guard and watching the Codex/Pi/MCP tests go red. Out of scope, noted for follow-up: the `omni exec`/pipe path reads piped stdout only and never sees the child exit code, so hardening it needs exec.rs to capture and forward the code — a separate change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #120
Problem
A command that failed could be distilled into output that reads as success. That is the worst failure mode: a fabricated success terminates investigation, while a fabricated error only costs a retry. In the filed case a
vaultcall that failedexit=2on a network timeout surfaced a clean, plausible, fictional["n8n"].Root cause (code-visible, not the unreproducible
["n8n"]itself): OMNI'snormalizelayer parsed each agent's failure signal and threw it away.exit_codeCodexInputtoolResponse.isError#[allow(dead_code)]result.isErrortool_responseis a bare string"Error: Exit code N…"None(safe by accident)So failed commands from Codex/Pi/MCP ran the full distiller.
Fix
The invariant from the issue: a non-zero exit passes through verbatim, never distilled. Implemented at the one place format knowledge already lives:
NormalizedInputgainsfailed, set from each agent's own signal (exit_code != 0,isError).post_tool::process_payload:if normalized.failed { return None }— passthrough, host keeps the raw bytes at zero marker cost.is_command_errortext heuristic: the guard keys on exit status only, so a successful build log full of the word "error" still distills. Net savings on passing commands are untouched.Claude Code needs no code change (its failure string already passes through); a regression test locks that in so a future lenient parser can't silently reintroduce the fabrication.
Verified end-to-end (real binary,
omni --hook)docker buildexit_code 1, 9207 Bdocker buildexit_code 0, 9207 BTeeth: disabling the guard turns the Codex/Pi/MCP tests red; restoring it makes them green.
Gates:
cargo fmt/clippy -D warnings/cargo test(1027 passed, 0 failed) — run on local Homebrew 1.94.0, not CI's pinned 1.97.0, so CI clippy is the source of truth.Out of scope (follow-up)
The
omni exec/pipe.rspath reads piped stdout only and never sees the child exit code; hardening it needsexec.rsto capture and forward the code — a separate change, not this one.PR Auto Describe
Summary
Added
failedfield toNormalizedInputto track non-zero command exits or error signals per-agent. Failed commands now pass through verbatim instead of being distilled — preventing fabricated success summaries that would terminate investigation early. Fixes #120 with full test coverage across all affected parsers.Key Changes
NormalizedInput.failed: booltracks command failure per agent formatprocess_payloadreturnsNone(verbatim pass) whenfailed == trueisErrorflags wired intofailedwhere availableDetailed Breakdown
normalize_claude_code: always setsfailed: false— failures arrive as bare strings, not objects, so never reach the normalizernormalize_pi: readsisErrorboolean intofailednormalize_opencode/normalize_vscode_continue/normalize_aider: setfailed: false— no exit/error signal in payloadnormalize_codex: readsexit_codefield;failed = exit_code.is_some_and(|c| c != 0)normalize_generic_mcp: readsisErrorfromMcpResultContentintofailedprocess_payload: early returnNonewhennormalized.failed— host keeps original bytes; no risk of a distilled success hiding a real failureNotes
failed: falsehere documents intent and guards future parser changes.Breaking Changes
None.
Last updated: 2026-07-21 00:51:52