Skip to content

fix(toml): fail open when batch filters strip every line - #253

Merged
fajarhide merged 1 commit into
fajarhide:mainfrom
snowyukitty:fix/224-toml-empty-fail-open
Jul 29, 2026
Merged

fajarhide merged 1 commit into
fajarhide:mainfrom
snowyukitty:fix/224-toml-empty-fail-open

Conversation

@snowyukitty

@snowyukitty snowyukitty commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

What

  • introduce a batch outcome that distinguishes filtered output from an unhandled empty result
  • make PostToolUse decline the rewrite and pipe mode return the original bytes when a matching filter strips every line without fallback_message
  • keep explicit fallback_message values, surviving signal lines, and stream-mode suppression unchanged

Why

TomlFilter::apply returned an empty string when line rules removed every row and the filter had no fallback_message. The post-hook accepted that as 100% compression and replaced non-empty stdout with nothing.

Simply returning the input is not sufficient: it misses the size guardrail and falls through to another distiller, which changed the synthetic 200-row Black reproduction into Build: ok. The batch outcome now makes fail-open explicit at both caller boundaries.

The reported Black trigger is deliberately synthetic; normal Black output also prints a summary line, which survives the filter.

Validation

The three Rust files in this patch were fully validated before the upstream 0.6.8 release cut:

  • cargo fmt --all -- --check (Rust 1.97.0 and 1.97.1)
  • cargo check --all-targets --all-features
  • cargo build
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --lib: 526 passed
  • cargo test --test toml_filter_integration: 44 passed
  • focused batch filter tests: 3 passed
  • mutation check: removing the new passthrough branch fails both hook-boundary regressions

After rebasing onto current main, those Rust files are byte-identical and the pinned Rust 1.97.0 formatter still passes. On this Windows host, Application Control blocks the newly hashed 0.6.8 build-script executable before check/test can run; it produces no compiler diagnostic or failed assertion. CI should provide the authoritative pinned-toolchain full-suite result.

Closes #224

Greptile Summary

This PR fixes a data-loss bug (#224) where TomlFilter::apply returned an empty string when its line rules stripped every row and no fallback_message was configured. The post-hook accepted that empty string as valid 100%-compression output and silently replaced non-empty tool stdout with nothing.

  • Introduces BatchFilterOutcome::Passthrough | Filtered(String) and apply_batch, which distinguishes an all-stripped-no-fallback result from an intentional filter output — apply is left unchanged so stream-mode (one-line-at-a-time) callers that expect \"\" to mean "drop this line" are unaffected.
  • post_tool.rs returns None on Passthrough (host keeps original bytes) and records a passthrough telemetry event; pipe.rs returns the original input_text with Route::Passthrough so the output flows through the normal downstream logging path.
  • Seven new tests cover the all-stripped, configured-fallback, surviving-signal, and stream-suppression outcomes across all three changed Rust files.

Confidence Score: 4/5

The core fix is logically sound and well-covered by targeted unit tests; the one concern is a test-to-signal-file coupling that would surface only if black.toml is updated

The apply_batch condition correctly mirrors the on_empty check already inside apply, so the two paths stay in sync. Both hook boundaries handle Passthrough correctly — post_tool returns None and pipe returns the original bytes with Route::Passthrough. The only concern is that the new post_tool.rs integration tests load the live signals/tools/black.toml via load_all_filters() and will break if that file ever gains an on_empty/fallback_message field, even though the hook code itself would remain correct.

Files Needing Attention: src/hooks/post_tool.rs — the two new integration tests are implicitly coupled to the content of signals/tools/black.toml

Important Files Changed

Filename Overview
src/pipeline/toml_filter.rs Introduces BatchFilterOutcome enum and apply_batch method; cleanly separates "all-lines-stripped with no fallback" (Passthrough) from "filtered result" (Filtered); refactors capped_filter to use struct update syntax; adds 4 focused unit tests covering all outcome branches
src/hooks/post_tool.rs Replaces direct apply call with apply_batch, early-returns None on Passthrough (fail-open), records telemetry via record_passthrough; new integration tests rely on the production black.toml signal file content, creating a coupling that could silently break if that file gains a fallback_message
src/hooks/pipe.rs Replaces direct apply call with apply_batch, propagates Route::Passthrough when filter strips every line (returning original input bytes), adds a regression test; asymmetry with post_tool.rs (no explicit record_passthrough call) is intentional — telemetry flows through the Route enum downstream
CHANGELOG.md Adds an Unreleased entry documenting the #224 fix; entry is unusually verbose but technically accurate

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["TOML filter matches command"] --> B["apply_batch(input)"]
    B --> C{"apply() result"}
    C -->|"output non-empty\n(signal survives or fallback set)"| D["Filtered(output)"]
    C -->|"output empty/whitespace\nAND on_empty is None\nAND input non-empty"| E["Passthrough"]
    C -->|"on_empty is Some\n(output = fallback from apply)"| D

    D --> F{"beats_guardrail?"}
    F -->|"Yes"| G_hook["post_tool: return Some(distilled)\npipe: Route::Keep"]
    F -->|"No"| H["Fall through to distiller"]

    E --> I_hook["post_tool: record_passthrough\nreturn None (host keeps original)"]
    E --> I_pipe["pipe: return input_text\nRoute::Passthrough"]
Loading

Fix All in Claude Code Fix All in Codex Fix All in Cursor Fix All in Conductor

Prompt To Fix All With AI
### Issue 1
src/hooks/post_tool.rs:973-1034
**Tests couple hook logic to production signal file content**

`declines_a_batch_filter_that_removes_every_line` and `still_applies_a_batch_filter_when_a_signal_survives` both call `process_payload` with `command: "black --check ."`. `process_payload` calls `load_all_filters()`, which includes the embedded `signals/tools/black.toml`. Those tests therefore depend on that file having no `fallback_message`. If `black.toml` gains an `on_empty`/`fallback_message` in a future signal update, `apply_batch` would return `Filtered(fallback)` instead of `Passthrough`, flipping the first test from pass to fail — even though the hook logic is untouched.

The three unit tests in `toml_filter.rs` side-step this by using the in-memory `black_noise_filter` fixture. The same approach would make the `post_tool.rs` hook-path tests resilient: inject a `TomlFilter` directly (or a thin test-only override for `load_all_filters`) rather than relying on a particular state of the shipped signal file.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(toml): fail open on empty batch outp..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Comment thread src/hooks/post_tool.rs
Comment on lines 973 to 1034
);
}

/// #224: the Black TOML filter stripped every row and returned an empty
/// string. The hook accepted it as 100% compression and replaced non-empty
/// stdout with nothing. A batch zero-state with no explicit fallback must
/// decline the rewrite so the host retains its original result.
#[test]
fn declines_a_batch_filter_that_removes_every_line() {
let content = (0..200)
.map(|i| format!("would reformat src/module_{i}.py"))
.collect::<Vec<_>>()
.join("\n");
let payload = json!({
"tool_name": "Bash",
"tool_input": {"command": "black --check ."},
"tool_response": bash_response(&content),
})
.to_string();

assert!(
process_payload(&payload, None, None).is_none(),
"the hook must decline instead of replacing stdout with an empty or fabricated result"
);
}

/// The counter-case: a Black summary is a real signal that survives the
/// line filter, so the TOML filter must still win rather than turning every
/// matching command into passthrough.
#[test]
fn still_applies_a_batch_filter_when_a_signal_survives() {
let mut content = String::new();
for i in 0..20 {
content.push_str(&format!("would reformat src/module_{i}.py\n"));
}
content.push_str("Oh no! 20 files would be reformatted.");
let payload = json!({
"tool_name": "Bash",
"tool_input": {"command": "black --check ."},
"tool_response": bash_response(&content),
})
.to_string();

let out = process_payload(&payload, None, None).expect("signal should be distilled");
let v: serde_json::Value = serde_json::from_str(&out).expect("valid hook json");
let stdout = v["hookSpecificOutput"]["updatedToolOutput"]["stdout"]
.as_str()
.expect("stdout is a string");

assert!(
stdout.contains("Oh no! 20 files would be reformatted."),
"the surviving signal was lost: {stdout}"
);
assert!(
!stdout.contains("would reformat src/module_"),
"configured noise rows survived: {stdout}"
);
}

/// #212: Claude Code caps the hook payload, and above roughly the same size
/// it persists the **raw** output to a file, previews the **raw** first 2 KB,
/// and drops whatever the hook returns. A payload arriving at the cap is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests couple hook logic to production signal file content

declines_a_batch_filter_that_removes_every_line and still_applies_a_batch_filter_when_a_signal_survives both call process_payload with command: "black --check .". process_payload calls load_all_filters(), which includes the embedded signals/tools/black.toml. Those tests therefore depend on that file having no fallback_message. If black.toml gains an on_empty/fallback_message in a future signal update, apply_batch would return Filtered(fallback) instead of Passthrough, flipping the first test from pass to fail — even though the hook logic is untouched.

The three unit tests in toml_filter.rs side-step this by using the in-memory black_noise_filter fixture. The same approach would make the post_tool.rs hook-path tests resilient: inject a TomlFilter directly (or a thin test-only override for load_all_filters) rather than relying on a particular state of the shipped signal file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/post_tool.rs
Line: 973-1034

Comment:
**Tests couple hook logic to production signal file content**

`declines_a_batch_filter_that_removes_every_line` and `still_applies_a_batch_filter_when_a_signal_survives` both call `process_payload` with `command: "black --check ."`. `process_payload` calls `load_all_filters()`, which includes the embedded `signals/tools/black.toml`. Those tests therefore depend on that file having no `fallback_message`. If `black.toml` gains an `on_empty`/`fallback_message` in a future signal update, `apply_batch` would return `Filtered(fallback)` instead of `Passthrough`, flipping the first test from pass to fail — even though the hook logic is untouched.

The three unit tests in `toml_filter.rs` side-step this by using the in-memory `black_noise_filter` fixture. The same approach would make the `post_tool.rs` hook-path tests resilient: inject a `TomlFilter` directly (or a thin test-only override for `load_all_filters`) rather than relying on a particular state of the shipped signal file.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor Fix in Conductor

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. The coupling is deliberate: the test drives the shipped black.toml through the real hook path, which is where #224 was observed. A test-only override of load_all_filters would be indirection added for one assertion, and the toml_filter.rs unit tests already cover the outcome logic against an in-memory fixture.

If black.toml ever gains a fallback_message, a red test here is the signal worth having, since the filter's zero-state would have changed and someone should confirm the hook still declines for the filters that have none.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a sound justification. The test-as-canary argument is valid — if black.toml gains fallback_message, the red test correctly surfaces that the zero-state contract has changed and needs a conscious review. The toml_filter.rs unit tests cover the outcome logic in isolation; these cover the full hook path. The coupling is load-bearing rather than accidental. Fair call, I'll leave it.

@fajarhide
fajarhide self-requested a review July 29, 2026 07:58

@fajarhide fajarhide left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is green on 89723d5 across all three jobs, and the change is correct. Three notes, none of them blocking.

Verified locally in a worktree before the workflow was approved, on toolchain 1.97.1 rather than the pinned 1.97.0, so CI remains the authoritative result:

  • cargo fmt --all -- --check and cargo clippy --all-targets --all-features -- -D warnings: clean
  • cargo test --lib: 525 passed
  • cargo test --test toml_filter_integration: 44 passed
  • Mutation check: replacing the apply_batch guard condition with if false fails declines_a_batch_filter_that_removes_every_line and passes_through_a_batch_filter_that_removes_every_line, and leaves the surviving-signal counter-case passing.

The guard mirrors the on_empty check already inside apply, apply itself is untouched so stream mode keeps its per-line semantics, and both hook boundaries follow the fail-open pattern the format-sniff and host-cap gates above them already use.

1. The CHANGELOG entry does not say what the fix costs. The short-circuit takes the distiller out of the picture for every TOML filter that strips its whole input, which #224 counts as 15 of 45 shipped filters. On kubectl, make and terraform that is compression traded for safety. The trade is right, but the entry only describes the safety half of it.

2. Build: ok is #250, and this PR does not close it. The fabrication the passthrough avoids is still reachable on any build-routed command that no TOML filter matches. On 0.6.7:

$ omni exec rustc --print cfg
Build: ok[OMNI Active] ⏺ 99.3% reduction (1.2 KB → 9 B) 77ms

1,208 bytes of configuration data for a 9-byte success claim. src/distillers/build.rs:100 still returns Build: ok whenever no segment scores Critical or Important, and build.rs is on #250's list of nine distillers that never call require_parsed.

3. Tag the new passthrough event. record_passthrough(clean_command, content.len()) writes the same string as the guardrail passthrough at post_tool.rs:547, so the two cannot be told apart in passthrough_events. The two gates above it carry a reason ([host output cap], [{format}]). &format!("{clean_command} [toml zero-state]") keeps the telemetry readable.

@fajarhide
fajarhide merged commit 3d13584 into fajarhide:main Jul 29, 2026
4 checks passed
@fajarhide

Copy link
Copy Markdown
Owner

Merged, thanks for the contribution.

Two things made this quick to review. The mutation check in your PR body was real: I reran it here, and removing the passthrough branch does fail both hook regressions while leaving the surviving-signal counter-case green. And you said plainly that the Black trigger is synthetic and that your Windows host could not run the full suite, instead of letting CI discover it.

The two notes I left are follow-ups on our side. Nothing to redo here.

fajarhide added a commit that referenced this pull request Aug 2, 2026
* fix(store): name the gate that produced each passthrough row

`passthrough_events` has no reason column, so the reason rides on the end of
the command string. Two of the four gates wrote a tag and two wrote the bare
command, and after #253 the untagged pair sat on opposite sides of the
pipeline recording the same string for opposite findings:

  black --check .   a signal file stripped its whole input, no fallback_message
  date              a distiller produced something too weak to keep

Read as one bucket those are the same row. The first names a filter that needs
a zero-state written for it; the second names a distiller to improve. This
table is queried by hand to decide what to build next, so the ambiguity costs
work rather than correctness.

`recent_passthroughs` exists only so the tag can be asserted. Nothing in the
shipped binary reads this table yet.

Closes #254

* docs(changelog): record the passthrough gate tags (#254)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TomlFilter hands the agent an empty result when its strip rules match every line: 15 of 45 filters have no fallback_message behind them

2 participants