Skip to content

Stop kcap's stderr being read as hook output, and stop it carrying the raw server URL - #444

Merged
realtonyyoung merged 5 commits into
mainfrom
tonyyoung/ai-1618-gemini-stderr-shadowing
Aug 4, 2026
Merged

Stop kcap's stderr being read as hook output, and stop it carrying the raw server URL#444
realtonyyoung merged 5 commits into
mainfrom
tonyyoung/ai-1618-gemini-stderr-shadowing

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

Linear: AI-1618, AI-1729

Two findings that share one root: kcap's stderr is a channel something else reads. Both were found by auditing what kcap actually writes there on Gemini hook paths.


AI-1618 — Gemini consumes kcap's stderr as hook output

Gemini's hook runner selects the text it parses as stdout.trim() || stderr.trim(), in a child.on("close") handler that never consults the event name. Only SessionStart wrote to stdout, so on SessionEnd, Notification, and any event we do not route, kcap's stderr became the hook's result.

This is live, not theoretical. The new test's RED output was the bug in production form:

Expected to be equal to "" but received "[kcap] gemini-hook session-end/gemini: HTTP 400"

One correction to the issue's analysis

The issue records this as safely benign because "the plain-text fallback never synthesises a block." It does:

convertPlainTextToHookOutput(text, exitCode) {
  if (exitCode === EXIT_CODE_SUCCESS)               decision: "allow"
  else if (exitCode === EXIT_CODE_NON_BLOCKING_ERROR)  decision: "allow", "Warning: " + text
  else                                              decision: "deny", reason: text
}
// EXIT_CODE_SUCCESS = 0, EXIT_CODE_NON_BLOCKING_ERROR = 1

decision: "deny" is what isBlockingDecision() returns true for — a blocked session, not junk context.

kcap stays out of that band only because "hook" is in CrashReporter.FailOpenCommands, which turns HttpClientExtensions.EnsureAbsolute's Environment.Exit(2) into a throw and makes Program.cs's top-level catch return 0. That is a load-bearing coupling across three files that nothing stated. It is now written down next to the command and pinned by a test, so removing "hook" from that set fails loudly instead of turning a malformed server_url into a blocked Gemini session.

The fix

The invariant is now structural instead of per-path discipline — which is precisely why it held for one event out of four. A write-once sink is created as soon as a hook_event_name is recognised and flushed from a finally. A path with a real payload (the SessionStart memory envelope) claims the single write first; everything else degrades to an explicit {"continue":true}. Five scattered emit calls collapse into one guarantee, and a future early return cannot reopen the hole.

Input with no parseable hook_event_name still stays silent — we cannot know a hook fired at all, and emitting a decision object into some other consumer's stdout is its own hazard.

eventName is deliberately not filtered to the events we route, since Gemini's close handler does not filter either.

WriteSessionStartOutput is removed: the refactor left it with no production call site, and its throwing-writer contract now belongs to the sink.


AI-1729 — the unreachable-API line printed the raw server URL

Console.Error.WriteLine($"{UnreachableHint} {baseUrl} {ex.Message}");

A server_url may carry userinfo credentials. UnusableUrlDiagnostic — same assembly, same stream, same hazard — has sanitized for exactly this since it was written; this call bypassed it.

Reachable from the hook path, not just interactive commands: AgentHookPoster.PostAsync calls it on any HttpRequestException, so an unreachable or misconfigured server printed the credential-bearing URL on every lifecycle POST, for every vendor. Seventeen other call sites share it.

Control characters are now stripped from both variable components. Guarding only the URL would leave the other half of the interpolation open — and before AI-1618's fix, an embedded newline here was a line-injection vector into content the model reads.

The fixed hint's own \r is left alone: not attacker-reachable, and changing it would alter output every existing call site produces. The tests assert "the payload never begins a line" rather than "the output holds no control character" for that reason — the blanket form would have been testing the fixed prefix and failing for a reason unrelated to injection.


Verification

  • RED watched before every implementation. For AI-1729 the renderer was first added reproducing today's raw behaviour, so the tests were seen failing on the credential and injection assertions (5 fail / 4 pass) rather than passing the moment the API existed.
  • Mutation-tested. Removing the write-once guard survived the unit suite at first; direct sink tests were added, and both now kill it.
  • Positive controls. The failed-POST test asserts the diagnostic was written before checking stdout shadows it — without that it passes vacuously the day the diagnostic moves. The sanitization tests assert the host and cause survive, so the guard cannot be satisfied by sanitizing to nothing.
  • Tests assert what Gemini would actually do with the bytes — selection, parse, and isBlockingDecision — not merely that some bytes were written.
  • 144 unit + 8 integration Gemini tests, plus every test class touching HttpClientExtensions/AgentHookPoster (AgentHookPosterTests, the three HttpClientExtensions* classes, UnusableUrlGuardTests, UnusableUrlDiagnosticTests, SetupCommandTests, both SpawnBeforePost classes): all green.
  • AOT publish clean, no errors. The one CS8604 in GeminiHookCommand is inherited — proved against a detached origin/main worktree (same warning, line 272 → 333, shifted by added lines), not assumed.

Gemini behaviour measured on gemini 0.53.0, read from the installed bundle. Treated as a version-specific observation: the mitigation does not depend on it, since emitting a valid non-blocking object is correct under any of these selection rules.

No README change — no user-facing CLI surface changes (no new command, flag, default, or prerequisite).

🤖 Generated with Claude Code

Gemini's hook runner selects the text it parses as `stdout.trim() ||
stderr.trim()`, in a `child.on("close")` handler that never consults the
event name. Only SessionStart wrote to stdout, so on SessionEnd and
Notification — and on any event we do not route — kcap's stderr became
the hook's result. That is live today: a rejected lifecycle POST makes
`AgentHookPoster` write `[kcap] gemini-hook session-end/gemini: HTTP 400`,
and Gemini consumes it as hook-sourced content.

The invariant is now structural rather than a discipline each early
return has to remember, which is how it came to hold for one event out of
four: a write-once sink is created as soon as a `hook_event_name` is
recognised and flushed from a `finally`. A path with a real payload (the
SessionStart memory envelope) claims the single write first; everything
else degrades to an explicit `{"continue":true}`. Input with no parseable
`hook_event_name` still stays silent — we cannot know a hook fired.

Also documents the second half of the contract, which the issue's
analysis had backwards. Gemini's plain-text fallback is not
unconditionally non-blocking: it maps exit 0 and 1 to `decision: "allow"`
but any other code to `decision: "deny"`, which `isBlockingDecision()`
honours. kcap stays out of that band only because `hook` is in
`CrashReporter.FailOpenCommands`, which turns `EnsureAbsolute`'s
`Environment.Exit(2)` into a throw and makes the top-level catch return
0 — a load-bearing coupling across three files that nothing stated. It is
now pinned by a test.

Tests assert what Gemini would actually do with the bytes, not merely
that some were written, and the failed-POST case asserts the diagnostic
really was written before checking that stdout shadows it — without that
positive control the test passes vacuously the day the diagnostic moves.

`WriteSessionStartOutput` is dropped: the refactor left it with no
production call site, and its throwing-writer contract now belongs to the
sink. Behaviour measured on gemini 0.53.0.
`WriteUnreachableError` interpolated the base URL raw:

    Console.Error.WriteLine($"{UnreachableHint} {baseUrl} {ex.Message}");

A `server_url` may carry userinfo credentials, and `UnusableUrlDiagnostic`
— same assembly, same stream, same hazard — has sanitized for exactly
that since it was written. This call bypassed it.

It is reachable from the HOOK path, not just interactive commands:
`AgentHookPoster.PostAsync` calls it on any `HttpRequestException`, so an
unreachable or misconfigured server printed the credential-bearing URL on
every lifecycle POST, for every vendor. Seventeen other call sites across
Recap, Review, Projects, ValidatePlan, Curate, Errors, OAuthLoginFlow and
Program.cs share it.

Control characters are now stripped from BOTH variable components, not
just the URL. Guarding only the URL would have left the other half of the
interpolation open, and this line goes to a stream harnesses parse — on
Gemini, kcap's stderr is read as the hook's own result when stdout is
empty, so an embedded newline was a line-injection vector into content
the model reads.

The fixed hint's own `\r` is deliberately left alone: it is not
attacker-reachable, and changing it would alter the output every existing
call site produces. The tests assert "the payload never begins a line"
rather than "the output holds no control character" for that reason — the
blanket form would have been testing the fixed prefix and failing for a
reason unrelated to injection.

Rendering is split into a pure `RenderUnreachableError` so the guard is
testable without capturing Console.Error. The host and the failure cause
both survive sanitization, which is what keeps the line actionable; a
test pins that, so this cannot be "fixed" by sanitizing to nothing.

Also corrects the doc comment, which claimed the method writes a
structured JSON error. It writes an interpolated line.
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown

AI-1618

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Ensure Gemini hooks always emit stdout JSON; sanitize unreachable-server stderr

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Guarantee one non-blocking JSON object on stdout for every recognised Gemini hook event.
• Prevent stderr diagnostics from becoming hook output; keep hook exit codes out of Gemini’s deny
 band.
• Sanitize unreachable-server stderr output (strip credentials/control chars) and add regression
 tests.
Diagram

graph TD
A["Gemini hook runner"] --> B["kcap GeminiHookCommand.Handle"] --> C{"hook_event_name present?"}
C -->|"no"| H["Exit silent"]
C -->|"yes"| D["HookResultWriter (stdout)"] --> E["Dispatch handlers"] --> F["Stdout JSON result"] --> A
E --> G["Stderr diagnostics (sanitized)"] --> A
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Silence all stderr during Gemini hooks
  • ➕ Eliminates risk of stderr being consumed as hook output even if stdout emission regresses
  • ➖ Loses actionable diagnostics for real operational failures (unreachable server, auth, POST rejection)
  • ➖ Hard to guarantee across all call paths without broad plumbing changes
2. Change Gemini runner to ignore stderr or consult event name
  • ➕ Fixes the root contract flaw at the consumer; reduces coupling to kcap’s output discipline
  • ➖ Not under kcap’s control; requires Gemini changes and version coordination
  • ➖ Still leaves other consumers with similar stdout/stderr selection patterns
3. Emit structured JSON to stderr and stdout
  • ➕ Keeps diagnostics machine-readable
  • ➖ Doesn’t help when the consumer treats stderr as the primary/only output channel on empty stdout
  • ➖ Increases the risk of concatenated/multi-object payloads causing parse fallback

Recommendation: Current approach is the best mitigation within kcap’s control: make the “stdout always has one non-blocking JSON object for recognised events” invariant structural (finally-backed, write-once) so early returns can’t reopen stderr shadowing. Keep stderr diagnostics, but sanitize and control-character-strip them to avoid credential leakage and line-injection in harness-parsed streams.

Files changed (6) +507 / -92

Bug fix (2) +125 / -55
HttpClientExtensions.csSanitize unreachable-server stderr line and make it renderable/testable +24/-2

Sanitize unreachable-server stderr line and make it renderable/testable

• Introduces RenderUnreachableError() to build the unreachable-API diagnostic using UnusableUrlDiagnostic.Sanitize and control-character stripping. Updates WriteUnreachableError() to use the safe renderer, preventing credential leakage and line-injection via interpolated variables.

src/Capacitor.Cli.Core/HttpClientExtensions.cs

GeminiHookCommand.csMake Gemini hook stdout emission structural via write-once sink +101/-53

Make Gemini hook stdout emission structural via write-once sink

• Refactors SessionStart output into a pure RenderSessionStartPayload() and adds HookResultWriter to guarantee exactly one stdout JSON object for any recognised hook invocation. Moves dispatch logic into a helper and ensures a backstop {"continue":true} is written from a finally block so stderr diagnostics cannot become hook output.

src/Capacitor.Cli/Commands/GeminiHookCommand.cs

Tests (4) +382 / -37
GeminiStderrShadowedOnPostFailureTests.csIntegration coverage for stderr shadowing on rejected non-SessionStart posts +123/-0

Integration coverage for stderr shadowing on rejected non-SessionStart posts

• Adds WireMock-backed tests proving that, when lifecycle/notification POSTs are rejected and diagnostics are written to stderr, stdout still contains {"continue":true} and therefore wins Gemini’s stdout||stderr selection. Includes positive-control assertions that stderr was actually produced.

test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs

GeminiHookOutputContractTests.csPin Gemini hook output/exit-code contract and sink behavior +181/-0

Pin Gemini hook output/exit-code contract and sink behavior

• Adds unit tests asserting that every recognised event emits a single non-blocking JSON object on stdout across fast-return paths, while non-hook-looking input stays silent. Also tests the HookResultWriter’s write-once semantics and swallowing of throwing/partial stdout writes, and pins that the hook command remains fail-open (exit code < 2).

test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs

GeminiSessionStartMemoryTests.csSwitch SessionStart memory tests to pure payload renderer +3/-37

Switch SessionStart memory tests to pure payload renderer

• Updates tests to call RenderSessionStartPayload() directly instead of writing through a TextWriter. Removes writer-failure tests from this file (now covered by the sink-focused contract tests).

test/Capacitor.Cli.Tests.Unit/GeminiSessionStartMemoryTests.cs

UnreachableErrorTests.csAdd tests for unreachable-server stderr sanitization and line-safety +75/-0

Add tests for unreachable-server stderr sanitization and line-safety

• Introduces unit tests validating that RenderUnreachableError() drops credentials, preserves host/cause, handles awkward credential shapes, strips control characters to prevent second-line injection, and renders safely for blank/null inputs.

test/Capacitor.Cli.Tests.Unit/Http/UnreachableErrorTests.cs

@qodo-code-review

qodo-code-review Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. ValueKind checks in tests ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
New tests inspect JSON by directly checking JsonElement.ValueKind/JsonValueKind, instead of
using the project’s JsonElementExtensions helpers. This violates the preferred JSON inspection
pattern and can lead to inconsistent handling across the codebase.
Code

test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs[R109-112]

+        await Assert.That(writer.ToString()).IsEqualTo("""{"hookSpecificOutput":{"additionalContext":"memory"}}""");
+        using var doc = JsonDocument.Parse(writer.ToString());   // throwing IS the failure
+        await Assert.That(doc.RootElement.ValueKind).IsEqualTo(JsonValueKind.Object);
+    }
Evidence
PR Compliance ID 2 requires JSON inspection to use JsonElementExtensions instead of direct
JsonElement.ValueKind/JsonValueKind checks. The added tests explicitly assert
doc.RootElement.ValueKind equals JsonValueKind.Object in multiple places.

CLAUDE.md: Use JsonElementExtensions for JSON inspection instead of checking JsonValueKind directly
test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs[109-112]
test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs[152-158]
src/Capacitor.Cli.Core/JsonElementExtensions.cs[5-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds new JSON inspection logic that checks `JsonElement.ValueKind` / `JsonValueKind` directly. Per the compliance checklist, JSON inspection should use the project’s `JsonElementExtensions` APIs instead of ad-hoc `ValueKind` checks.

## Issue Context
The new assertions in `GeminiHookOutputContractTests` currently do:
- `doc.RootElement.ValueKind == JsonValueKind.Object`
This is a direct `ValueKind` check.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs[109-112]
- test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs[152-158]
- src/Capacitor.Cli.Core/JsonElementExtensions.cs[5-19]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread test/Capacitor.Cli.Tests.Unit/GeminiHookOutputContractTests.cs
Code review caught that the stated invariant is stronger than the code
holds, and that my own tests disprove it.

`HookResultWriter.Write` consumes the claim BEFORE attempting the write,
so a throwing stdout leaves the output empty (throw before any byte) or
truncated (throw mid-payload), with the backstop no-opping in both cases
— and the stderr fallback back in play.
`A_throwing_write_is_swallowed_and_still_consumes_the_single_claim`
pins exactly those two shapes with its 0 and 5 arguments, while the class
remarks claimed every recognised firing writes exactly one JSON object
and that stdout always wins.

The behaviour is unchanged and remains the right trade: nothing here can
distinguish a zero-byte failure from a partial write, and appending a
second object onto a partial one is unparseable — which is the same
stderr fallback with extra steps. A stdout we cannot write to has no
recovery from inside this process.

So the prose moves to "exactly one write ATTEMPT", with the residue
named rather than papered over, at all four sites that overstated it: the
class remarks, the invariant comment in Handle, the sink method doc, and
the test-class remarks. AllowPayload similarly now says its
cannot-fail property is about RENDERING, not delivery.

Stating an invariant more absolutely than the code holds is what invites
the next person to "restore" it — here, by adding a retry that
manufactures the concatenated-object case.
The previous commit lumped both throwing-writer outcomes together as
"falls back to stderr". Only one of them does.

The selection is `stdout.trim() || stderr.trim()`, so a TRUNCATED stdout
is truthy and stays selected — stderr is never reached. The invalid JSON
fails to parse and degrades to plain text, which at the exit codes this
command returns is an allow carrying our own partial payload as junk
context. Only the zero-byte throw leaves stdout falsy and selects stderr,
which is the original bug surviving for that one case.

That also sharpens why a retry is wrong: it does not re-expose stderr, it
guarantees the truncated case — invalid, still-selected, model-visible
stdout.

The rationale now lives in exactly one place, on HookResultWriter, with
the class remarks, the Handle comment and the tests referencing it in a
line each. Repeating it at four sites is how the claim drifted twice —
first overstating the invariant, then mis-describing the residues while
correcting it — and it ran against this repo's "do not get too verbose in
comments" convention.

No behaviour change; no test assertion changed.
Qodo rule violation: the new tests checked JsonElement.ValueKind
directly, against this repo's "DO use JsonElementExtensions instead of
checking JSON value kind" convention.

The two reads now go through the helpers, and both say more than the
ValueKind check did:

- The backstop test reads Obj("hookSpecificOutput") back. Obj() yields
  null for any non-object root, so this proves the root shape AND that
  the first payload survived intact, in one read.
- AssertNonBlockingJsonObject reads Str("decision"). Str() returns null
  unless the root is an object AND the property is a string — the same
  two conditions Gemini applies before honouring a decision — so one
  read covers what the ValueKind check and TryGetProperty did together.
  An equality assertion against AllowPayload follows it, which is
  strictly stronger than a shape check and pins the object-ness that a
  null read cannot distinguish from a missing key.

The integration test's TryGetProperty("decision") is aligned to Str() for
the same reason.

Verified the new read is not vacuously null: temporarily setting
AllowPayload to {"decision":"deny"} fails on the decision assertion
itself, which precedes the equality assertion.
@realtonyyoung
realtonyyoung merged commit a859eb5 into main Aug 4, 2026
6 checks passed
@realtonyyoung
realtonyyoung deleted the tonyyoung/ai-1618-gemini-stderr-shadowing branch August 4, 2026 18:03
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.

1 participant