Skip to content

Preserve stderr so wrapped command failures are visible#14

Draft
hsoerensen wants to merge 1 commit into
codejunkie99:masterfrom
hsoerensen:fix/preserve-stderr-on-nonzero-exit
Draft

Preserve stderr so wrapped command failures are visible#14
hsoerensen wants to merge 1 commit into
codejunkie99:masterfrom
hsoerensen:fix/preserve-stderr-on-nonzero-exit

Conversation

@hsoerensen
Copy link
Copy Markdown
Contributor

@hsoerensen hsoerensen commented May 21, 2026

Issue
runProxy captured stderr from the executor and then never wrote it anywhere. Any command whose useful output lives on stderr was silently truncated to whatever the stdout filter produced.

Concrete case: a repo with a lefthook pre-push hook that enforces code coverage percentage. When the hook fails, git push exits 1 and writes the hook error plus 'failed to push some refs' to stderr. Through ztk the agent saw:

exit=1
output: ok

filterGitPush returned 'ok' for the empty stdout, the real failure on stderr was thrown away, and the agent had no idea why the push failed. In practice this causes real churn: agents loop, retry, or give up because the wrapper hides the actual error.

Fix
forward result.stderr to the user's stderr via compat.writeStderr. Stdout filtering is unchanged, so secret-masking filters like the env filter still run on failed commands. Tests cover stderr passthrough on both zero and nonzero exit, plus a regression test that env-style sensitive values (API_KEY etc.) stay masked when the underlying command exits nonzero.

Additional
ztk has no stderr filters today. Secrets that show up on stderr (URL-embedded credentials in git auth errors, tokens in verbose curl/ssh output) already reach the agent's environment when those commands run outside ztk.

I'm thinking it may be questionable for ztk to do any filtering of secrets at all, as that could well be the responsibility of another tool.

Note

Preserve stderr output from wrapped commands in runProxy

Previously, stderr from executed commands was silently discarded. Now runProxy forwards it verbatim to the process stderr via compat.writeStderr in non-test builds.

  • Extracts a processOutput helper in proxy.zig that returns both processed stdout and the original stderr as a ProcessedOutput struct.
  • Adds three unit tests covering stderr passthrough on zero and nonzero exits, and stdout masking of sensitive values.

Macroscope summarized dfed8b3.

runProxy captured stderr from the executor and then never wrote it anywhere. Any command whose useful output lives on stderr was silently truncated to whatever the stdout filter produced.

Concrete case: a repo with a lefthook pre-push hook that enforces coverage. When the hook fails, git push exits 1 and writes the hook error plus 'failed to push some refs' to stderr. Through ztk the agent saw:

    exit=1
    output: ok

filterGitPush returned 'ok' for the empty stdout, the real failure on stderr was thrown away, and the agent had no idea why the push failed. In practice this causes real churn: agents loop, retry, or give up because the wrapper hides the actual error.

Fix: forward result.stderr to the user's stderr via compat.writeStderr. Stdout filtering is unchanged, so secret-masking filters like the env filter still run on failed commands. Tests cover stderr passthrough on both zero and nonzero exit, plus a regression test that env-style sensitive values (API_KEY etc.) stay masked when the underlying command exits nonzero.

Note on stderr filtering: ztk has no stderr filters today. Secrets that show up on stderr (URL-embedded credentials in git auth errors, tokens in verbose curl/ssh output) already reach the agent's environment when those commands run outside ztk, so this PR does not introduce new exposure; it removes an accidental side effect of dropping the stream. A scrubber for URL-embedded credentials, GitHub/AWS tokens, and Bearer headers is a follow-up, but the current behavior silently turns real errors into 'ok', and that's the bigger bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 21, 2026 23:52
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors proxy output handling by centralizing stdout post-processing and introducing explicit stderr forwarding, with new unit tests validating the updated behavior.

Changes:

  • Replace inline stdout processing with a new processOutput() helper that returns processed stdout plus original stderr.
  • Update runProxy() to emit processed stdout via existing path and additionally write stderr to stderr.
  • Add tests covering stderr forwarding and stdout masking behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/proxy.zig
},
log_path,
) catch {};
if (processed.stderr.len > 0) compat.writeStderr(processed.stderr) catch {};
Comment thread src/proxy.zig
Comment on lines +101 to +108
fn processOutput(cmd: []const u8, result: executor.ExecResult, allocator: std.mem.Allocator) ProcessedOutput {
const filtered = applyFilters(cmd, result.stdout, allocator);
const final_bytes = maybeApplySession(cmd, filtered, allocator);
return .{
.stdout = final_bytes,
.stderr = result.stderr,
};
}
Comment thread src/proxy.zig
},
log_path,
) catch {};
if (processed.stderr.len > 0) compat.writeStderr(processed.stderr) catch {};
Comment thread src/proxy.zig
const result = try executor.exec(cmd_args, allocator, .filter_stdout_only);
const filtered = applyFilters(cmd_str, result.stdout, allocator);
const final_bytes = maybeApplySession(cmd_str, filtered, allocator);
const processed = processOutput(cmd_str, result, allocator);
Comment thread src/proxy.zig
stderr: []const u8,
};

fn processOutput(cmd: []const u8, result: executor.ExecResult, allocator: std.mem.Allocator) ProcessedOutput {
Comment thread src/proxy.zig
};
const processed = processOutput("git push", exec_result, arena.allocator());
try std.testing.expectEqualStrings(exec_result.stderr, processed.stderr);
try std.testing.expect(std.mem.indexOf(u8, processed.stdout, "ok") != null);
@hsoerensen
Copy link
Copy Markdown
Contributor Author

Closing and reopening as draft to investigate Copilot flagged issues.

@hsoerensen hsoerensen closed this May 22, 2026
@hsoerensen hsoerensen reopened this May 22, 2026
@hsoerensen hsoerensen marked this pull request as draft May 22, 2026 11:47
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.

2 participants