Skip to content

feat(mcp): redact secret values from exec output#357

Merged
jdx merged 6 commits intomainfrom
feat/mcp-exec-redaction
Mar 13, 2026
Merged

feat(mcp): redact secret values from exec output#357
jdx merged 6 commits intomainfrom
feat/mcp-exec-redaction

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Mar 13, 2026

Summary

  • Scans stdout/stderr from the MCP exec tool for resolved secret values and replaces them with [REDACTED] before returning output to the agent
  • Prevents agents from exfiltrating secrets via commands like printenv or echo $SECRET
  • Enabled by default; configurable via mcp.redact_output = false
  • Includes unit tests for the redaction logic (longest-first matching, empty/whitespace skipping, multiple occurrences)

Closes discussion #350

Test plan

  • Unit tests for redact_secrets function (5 tests, all passing)
  • Manual test: configure secrets, run fnox mcp, use exec to run printenv and verify values are redacted
  • Manual test: set mcp.redact_output = false and verify raw values are returned

🤖 Generated with Claude Code


Note

Medium Risk
Changes MCP exec behavior to post-process stdout/stderr and hide resolved secret values by default, which could affect tooling that expects exact output and carries some risk of false-positive/negative redaction.

Overview
Prevents secret exfiltration via MCP exec output by scanning captured stdout/stderr for resolved secret values and replacing matches with [REDACTED] before returning results to the agent.

Adds configurable control via mcp.redact_output (default true), wires it through config merging and the published JSON schema, and documents the new security behavior and limitations. Implements redaction using aho-corasick (leftmost-longest) and adds unit tests covering overlap/length/empty handling.

Written by Cursor Bugbot for commit a1b1b52. This will update automatically on new commits. Configure here.

jdx and others added 2 commits March 13, 2026 14:16
Scan stdout/stderr from the exec tool for resolved secret values and
replace them with [REDACTED] before returning to the agent. This
prevents agents from exfiltrating secrets via commands like `printenv`.

Enabled by default; disable with `mcp.redact_output = false`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the security posture of the mcp tool by introducing automatic redaction of secret values from command execution output. By scanning and replacing resolved secrets with [REDACTED] in stdout and stderr, it prevents agents from inadvertently or maliciously exfiltrating sensitive information through commands like printenv. This feature is enabled by default but can be configured, ensuring a more secure operational environment.

Highlights

  • Secret Redaction: Implemented automatic redaction of resolved secret values from the mcp exec tool's stdout/stderr output, replacing them with [REDACTED].
  • Security Enhancement: Prevented agents from exfiltrating secrets via commands like printenv or echo $SECRET.
  • Configuration: Introduced a new configurable option mcp.redact_output (defaulting to true) to enable or disable this redaction behavior.
  • Unit Tests: Added comprehensive unit tests for the redaction logic, covering scenarios like longest-first matching, skipping empty/whitespace secrets, and multiple occurrences.
Changelog
  • docs/guide/mcp.md
    • Updated the description of the exec tool to include details about secret redaction from stdout/stderr.
    • Clarified that agents cannot retrieve raw secret values via get_secret or subprocess output when redaction is enabled.
  • src/config.rs
    • Added a new redact_output boolean field to McpConfig with documentation, defaulting to true.
    • Implemented a redact_output getter method for McpConfig.
    • Integrated redact_output into the configuration merging logic.
  • src/mcp_server.rs
    • Modified the exec tool's output processing to conditionally call redact_secrets on stdout and stderr based on the mcp_config.redact_output() setting.
    • Introduced the redact_secrets function, which replaces secret values with [REDACTED], handling longest-first matching and skipping empty/whitespace secrets.
    • Added a new test module tests with five unit tests for the redact_secrets function, covering various redaction scenarios.
Activity
  • Unit tests for the redact_secrets function were added and are passing.
  • Manual tests are planned to verify redaction behavior and the mcp.redact_output configuration.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a valuable security feature to redact secret values from the exec tool's output in the MCP server. The changes are well-structured, with updates to configuration, documentation, and the server logic. The implementation of the redaction function redact_secrets has a potential correctness bug and performance bottleneck. I've provided a detailed comment with a suggested fix using the aho-corasick crate, which would make the implementation more robust and efficient. The addition of unit tests is great, but they would benefit from covering the edge case I've pointed out.

Comment thread src/mcp_server.rs Outdated
Comment on lines +475 to +496
fn redact_secrets(text: &str, secret_values: &[(String, String)]) -> String {
let mut values: Vec<&str> = secret_values
.iter()
.map(|(_, v)| v.as_str())
.filter(|v| !v.trim().is_empty())
.collect();

if values.is_empty() {
return text.to_string();
}

values.sort_unstable();
values.dedup();
// Sort longest first for greedy replacement
values.sort_by_key(|v| std::cmp::Reverse(v.len()));

let mut result = text.to_string();
for secret in &values {
result = result.replace(secret, "[REDACTED]");
}
result
}
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.

high

The current implementation of redact_secrets has a correctness issue and a performance issue.

Correctness Bug:
If a secret happens to be a substring of the replacement text "[REDACTED]" (e.g., DACT), the redaction will be applied incorrectly. The loop iterates and replaces secrets one by one on the evolving result string. This means already redacted parts can be re-redacted.
For example, with secrets secret and DACT, redacting "a secret" would produce "a [REDACTED]", which would then be re-scanned for DACT, resulting in "a [RE[REDACTED]ED]".

Performance:
The implementation iterates through each secret and calls String::replace on the entire output. This can be very inefficient for large outputs or a large number of secrets, as it results in multiple passes over the output string and repeated string allocations.

A more correct and performant approach is to use the Aho-Corasick algorithm, which is designed for multi-pattern string matching in a single pass. The aho-corasick crate is an excellent implementation for this. It will solve both the correctness and performance issues. You would need to add aho-corasick to your Cargo.toml (e.g., aho-corasick = "1.1").

fn redact_secrets(text: &str, secret_values: &[(String, String)]) -> String {
    // This requires adding the `aho-corasick` crate to your dependencies.
    let values: Vec<&str> = secret_values
        .iter()
        .map(|(_, v)| v.as_str())
        .filter(|v| !v.trim().is_empty())
        .collect();

    if values.is_empty() {
        return text.to_string();
    }

    // Using Aho-Corasick is more efficient and correct than repeated `replace`.
    // It avoids re-scanning the replacement string, and handles overlapping
    // matches correctly. `LeftmostLongest` ensures that if "abc" and "abcdef"
    // are secrets, "abcdef" is matched.
    let ac = match aho_corasick::AhoCorasick::builder()
        .match_kind(aho_corasick::MatchKind::LeftmostLongest)
        .build(&values)
    {
        Ok(ac) => ac,
        Err(_) => {
            // This should not happen with valid utf-8 strings.
            // Return original text as a safe fallback.
            return text.to_string();
        }
    };

    ac.replace_all(text, &vec!["[REDACTED]"; values.len()])
}

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 13, 2026

Greptile Summary

This PR hardens the MCP exec tool by redacting resolved secret values from stdout/stderr before returning output to the agent, directly addressing the secret-exfiltration path via printenv / echo $SECRET. Redaction is enabled by default and configurable via mcp.redact_output = false. The implementation uses Aho-Corasick with LeftmostLongest matching for a correct single-pass replacement, accompanied by unit tests covering the key edge cases.

  • All four issues raised in previous review threads have been addressed: the automaton is built fail-closed (propagates an error instead of returning unredacted output), replace_all is single-pass so already-placed [REDACTED] markers cannot be re-matched, secret values are trimmed before use so trailing newlines don't prevent matching, and short/empty secrets are filtered by MIN_REDACT_LENGTH.
  • The exec tool description in the Tools section of docs/guide/mcp.md (line 68) still states the tool "provides audit visibility, not secret isolation" — this is now inaccurate and contradicts the Security Considerations section added in this same PR.
  • redact_secrets is invoked twice per exec call (once for stdout, once for stderr), rebuilding the Aho-Corasick automaton from identical patterns each time. This is a minor performance inefficiency worth addressing for sessions with many secrets.

Confidence Score: 4/5

  • Safe to merge with a minor doc fix; the redaction logic is correct and all previously-flagged security issues have been resolved.
  • The core security implementation is solid: Aho-Corasick single-pass avoids marker corruption, fail-closed error handling avoids leaking raw output, and value trimming prevents newline-induced misses. The two remaining issues are a stale documentation sentence (logic concern, not a runtime bug) and a minor double-automaton-build inefficiency (style). Neither blocks correctness or security.
  • docs/guide/mcp.md — the exec tool description at line 68 is inconsistent with the new security model and should be updated before the PR is published.

Important Files Changed

Filename Overview
src/mcp_server.rs Adds redact_secrets (Aho-Corasick single-pass, fail-closed) and wires it into the exec tool for stdout/stderr. All four previously-flagged issues are addressed; minor inefficiency remains where the automaton is rebuilt twice per exec call.
src/config.rs Adds redact_output: Option<bool> to McpConfig with a redact_output() accessor defaulting to true, and correctly merges the field in the overlay merge loop alongside existing fields.
docs/guide/mcp.md Security Considerations section correctly updated, but the exec tool description (line 68) still says it provides "audit visibility, not secret isolation" — directly contradicting the new default-on redaction behaviour described a few lines later.
docs/public/schema.json Adds redact_output as a nullable boolean property to the MCP config schema object with an accurate description; no issues found.
Cargo.toml Adds aho-corasick = "1.1.4" dependency; version constraint is appropriate and the crate is a well-maintained standard library for multi-pattern string matching.
Cargo.lock Lock file updated to pin aho-corasick and its transitive dependency memchr; no issues.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant FnoxMcpServer
    participant Subprocess
    participant redact_secrets

    Agent->>FnoxMcpServer: exec(command)
    FnoxMcpServer->>FnoxMcpServer: ensure_resolved() — batch-resolve env=true secrets
    FnoxMcpServer->>FnoxMcpServer: build env_vars Vec<(name, raw_value)>
    FnoxMcpServer->>Subprocess: spawn(command, env=env_vars)
    Subprocess-->>FnoxMcpServer: stdout_buf + stderr_buf

    alt mcp_config.redact_output() == true
        FnoxMcpServer->>redact_secrets: redact_secrets(stdout_raw, env_vars)
        Note over redact_secrets: trim values, skip len < MIN_REDACT_LENGTH(3)<br/>build AhoCorasick (LeftmostLongest)<br/>replace_all → "[REDACTED]"
        redact_secrets-->>FnoxMcpServer: redacted stdout

        FnoxMcpServer->>redact_secrets: redact_secrets(stderr_raw, env_vars)
        Note over redact_secrets: automaton rebuilt from same patterns
        redact_secrets-->>FnoxMcpServer: redacted stderr
    else redact_output == false
        FnoxMcpServer->>FnoxMcpServer: stdout_raw / stderr_raw as-is
    end

    FnoxMcpServer-->>Agent: CallToolResult(redacted stdout + stderr)
Loading

Comments Outside Diff (1)

  1. docs/guide/mcp.md, line 68 (link)

    Stale exec tool description contradicts new security model

    The exec tool description still says it "provides audit visibility (you can see what commands were run), not secret isolation." This is now directly contradicted by the new Security Considerations section (added in this PR) which states that with redaction enabled agents cannot retrieve raw secret values through subprocess output.

    A user who reads only the Tools section will come away with the wrong mental model — that exec deliberately does not isolate secrets — while the Security Considerations section says the opposite.

    Consider updating the description to reflect the new default behaviour:

Fix All in Claude Code

Last reviewed commit: a1b1b52

Comment thread src/mcp_server.rs Outdated
Comment thread src/mcp_server.rs Outdated
Switch from sequential String::replace to Aho-Corasick with
leftmost-longest matching. This avoids corrupting [REDACTED] markers
when a secret value is a substring of the replacement text.

Also skip secrets shorter than 3 characters to avoid false-positive
redaction that corrupts output readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/mcp_server.rs
Trim whitespace from secret values before using them as both the
length guard and the Aho-Corasick pattern. Prevents trailing newlines
(common when secrets are loaded from files) from causing redaction
misses when the output contains the value without the newline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/mcp_server.rs Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread src/mcp_server.rs Outdated
Change redact_secrets to return Result<String, McpError> so that an
Aho-Corasick build failure propagates as an MCP error instead of
silently returning unredacted output. The exec tool now returns an
internal error rather than leaking secrets if the automaton cannot
be constructed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jdx jdx enabled auto-merge (squash) March 13, 2026 14:10
@jdx jdx merged commit 5dc437c into main Mar 13, 2026
15 of 16 checks passed
@jdx jdx deleted the feat/mcp-exec-redaction branch March 13, 2026 14:13
jdx pushed a commit that referenced this pull request Mar 13, 2026
### 🚀 Features

- **(sync)** add --local-file output target by
[@florian-lackner365](https://github.com/florian-lackner365) in
[#317](#317)

### 🐛 Bug Fixes

- properly handle auth prompt in batch providers by
[@johnpyp](https://github.com/johnpyp) in
[#349](#349)

### 🛡️ Security

- **(mcp)** redact secret values from exec output by
[@jdx](https://github.com/jdx) in
[#357](#357)

### 📦️ Dependency Updates

- lock file maintenance by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#344](#344)
- update jdx/mise-action digest to 5228313 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#351](#351)
- update swatinem/rust-cache digest to e18b497 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#352](#352)
- update taiki-e/upload-rust-binary-action digest to 381995c by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#353](#353)
- update dependency vue to v3.5.30 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#354](#354)
- update rust crate openssl-sys to v0.9.112 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#355](#355)
- update rust crate clap to v4.6.0 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#356](#356)

### New Contributors

- @florian-lackner365 made their first contribution in
[#317](#317)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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