feat(code): hide diff widget for credential files (#4593)

The interactive coding agent no longer renders diffs/contents of
credential files (e.g. `.env`), preventing secrets from appearing in the
terminal UI.

---

The file diff widget rendered the contents of `.env` and other
credential files, leaking secrets into the terminal UI and scrollback.
This adds a filename-based `is_sensitive_file_path` helper in
`file_ops.py` and redacts both the chat `DiffMessage` and the write/edit
HITL approval widgets for such files, showing a short notice instead of
the contents while preserving change stats for edit approvals.

Made by [Open
SWE](https://openswe.vercel.app/agents/7d568e82-479c-9e1a-6cde-af3aad536483)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-13 01:48:11 -04:00
committed by GitHub
parent d1f3afecdc
commit 4c49a24c4c
6 changed files with 330 additions and 20 deletions
+72
View File
@@ -149,6 +149,78 @@ def resolve_physical_path(
return None
_SENSITIVE_FILE_NAMES = frozenset(
{
".envrc",
".netrc",
"_netrc",
".pgpass",
".npmrc",
".pypirc",
".htpasswd",
".git-credentials",
"credentials",
"credentials.json",
"token.json",
"auth.json",
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
}
)
"""Basenames (lowercased) that commonly hold secrets and must not be rendered."""
_SENSITIVE_FILE_SUFFIXES = (
".pem",
".key",
".pfx",
".p12",
".keystore",
".jks",
)
"""File suffixes (lowercased) for private keys / keystores that hold secrets."""
def is_sensitive_file_path(path_str: str | None) -> bool:
"""Return whether a path points at a credential/secret file.
Best-effort, filename-based, case-insensitive heuristic. It matches `.env`
and its variants (e.g. `.env.local`), well-known credential filenames, and
private-key/keystore suffixes, and is used to suppress diff/content
rendering for those files so their contents are not shown in the terminal
UI or scrollback. It classifies by name only, not content, so
secret-bearing files with unrecognized names still render.
Args:
path_str: Filesystem path to classify (a display or absolute path).
May be `None` or empty.
Returns:
`True` if the basename matches a known credential pattern. A falsy
path returns `False` (nothing to classify). An unparseable path
returns `True` and logs a warning, so the redaction gate fails
closed on unexpected input rather than leaking.
"""
if not path_str:
return False
try:
name = Path(path_str).name.lower()
except (OSError, ValueError, TypeError):
logger.warning(
"is_sensitive_file_path: could not parse %r; treating as sensitive",
path_str,
)
return True
if not name:
return False
if name == ".env" or name.startswith(".env."):
return True
if name in _SENSITIVE_FILE_NAMES:
return True
return name.endswith(_SENSITIVE_FILE_SUFFIXES)
def format_display_path(path_str: str | None) -> str:
"""Format a path for display.
@@ -29,6 +29,7 @@ from deepagents_code.config import (
get_glyphs,
is_ascii_mode,
)
from deepagents_code.file_ops import is_sensitive_file_path
from deepagents_code.formatting import format_duration
from deepagents_code.input import EMAIL_PREFIX_PATTERN, INPUT_HIGHLIGHT_PATTERN
from deepagents_code.tool_display import (
@@ -3255,8 +3256,15 @@ class DiffMessage(Static):
classes="diff-header",
)
# Render the diff with per-line Statics (CSS-driven backgrounds)
yield from compose_diff_lines(self._diff_content, max_lines=100)
# Never render the contents of credential files (e.g. `.env`) — the diff
# would leak secrets into the terminal UI and scrollback.
if is_sensitive_file_path(self._file_path):
yield Static(
Content.styled("Diff hidden — file may contain credentials", "dim")
)
else:
# Render the diff with per-line Statics (CSS-driven backgrounds)
yield from compose_diff_lines(self._diff_content, max_lines=100)
def on_mount(self) -> None:
"""Set border style based on charset mode."""
@@ -10,10 +10,13 @@ from textual.content import Content
from textual.widgets import Markdown, Static
from deepagents_code import theme
from deepagents_code.file_ops import is_sensitive_file_path
if TYPE_CHECKING:
from textual.app import ComposeResult
_CREDENTIAL_NOTICE = "Contents hidden — file may contain credentials"
# Constants for display limits
_MAX_VALUE_LEN = 200
_MAX_LINES = 30
@@ -163,23 +166,28 @@ class WriteFileApprovalWidget(ToolApprovalWidget):
content = format_display_content(self.data.get("content", ""))
file_extension = self.data.get("file_extension", "text")
# Content with syntax highlighting via Markdown code block
lines = content.split("\n")
total_lines = len(lines)
# File header with line count
yield from _file_header(file_path, additions=total_lines if content else 0)
if total_lines > _MAX_LINES:
# Truncate for display
shown_lines = lines[:_MAX_LINES]
remaining = total_lines - _MAX_LINES
truncated_content = (
"\n".join(shown_lines) + f"\n... ({remaining} more lines)"
)
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
# Never render the contents of credential files (e.g. `.env`).
if is_sensitive_file_path(file_path):
yield from _file_header(file_path)
yield Static(Content.styled(_CREDENTIAL_NOTICE, "dim"))
else:
yield Markdown(f"```{file_extension}\n{content}\n```")
# Content with syntax highlighting via Markdown code block
lines = content.split("\n")
total_lines = len(lines)
# File header with line count
yield from _file_header(file_path, additions=total_lines if content else 0)
if total_lines > _MAX_LINES:
# Truncate for display
shown_lines = lines[:_MAX_LINES]
remaining = total_lines - _MAX_LINES
truncated_content = (
"\n".join(shown_lines) + f"\n... ({remaining} more lines)"
)
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
else:
yield Markdown(f"```{file_extension}\n{content}\n```")
class EditFileApprovalWidget(ToolApprovalWidget):
@@ -199,7 +207,11 @@ class EditFileApprovalWidget(ToolApprovalWidget):
additions, deletions = _count_diff_stats(diff_lines, old_string, new_string)
yield from _file_header(file_path, additions, deletions)
if not diff_lines and not old_string and not new_string:
# Never render the diff of credential files (e.g. `.env`); the stats
# above still convey that a change happened without exposing content.
if is_sensitive_file_path(file_path):
yield Static(Content.styled(_CREDENTIAL_NOTICE, "dim"))
elif not diff_lines and not old_string and not new_string:
yield Static("No changes to display", classes="approval-description")
elif diff_lines:
# Render content
+68 -1
View File
@@ -1,10 +1,77 @@
import shutil
import textwrap
from pathlib import Path
from typing import cast
import pytest
from langchain_core.messages import ToolMessage
from deepagents_code.file_ops import FileOpTracker, build_approval_preview
from deepagents_code.file_ops import (
FileOpTracker,
build_approval_preview,
is_sensitive_file_path,
)
@pytest.mark.parametrize(
"path",
[
".env",
".env.local",
".env.production",
"/home/user/project/.env",
"config/.ENV",
"credentials",
"~/.aws/credentials",
"credentials.json",
"TOKEN.JSON",
"~/.deepagents/.state/auth.json",
".git-credentials",
".netrc",
"_netrc",
".pgpass",
".npmrc",
".pypirc",
".htpasswd",
"id_rsa",
"id_ed25519",
"server.pem",
"private.KEY",
"cert.pfx",
"store.p12",
"app.keystore",
"release.jks",
],
)
def test_is_sensitive_file_path_matches_credentials(path: str) -> None:
assert is_sensitive_file_path(path) is True
@pytest.mark.parametrize(
"path",
[
"",
None,
"main.py",
"README.md",
"src/app.ts",
"environment.py",
"keyboard.json",
".envision",
],
)
def test_is_sensitive_file_path_ignores_regular_files(path: str | None) -> None:
assert is_sensitive_file_path(path) is False
def test_is_sensitive_file_path_fails_closed_on_unparseable_path() -> None:
"""A path that cannot be parsed is treated as sensitive, not rendered.
The wrong runtime type is the point of the test: it drives the defensive
branch that keeps a malformed `file_path` from crashing `compose()` and
from leaking as a non-sensitive file.
"""
assert is_sensitive_file_path(cast("str", 123)) is True
def test_tracker_records_read_lines(tmp_path: Path) -> None:
@@ -680,6 +680,43 @@ class TestToolCallMessageMarkupSafety:
assert msg.has_expandable_args is True
class TestDiffMessageCredentialRedaction:
"""`DiffMessage` must not render the contents of credential files."""
@staticmethod
def _texts(widget: DiffMessage) -> list[str]:
texts: list[str] = []
for child in widget.compose():
rendered = child.render()
texts.append(
rendered.plain if isinstance(rendered, Content) else str(rendered)
)
return texts
def test_env_file_diff_is_hidden(self) -> None:
diff = "@@ -1 +1 @@\n-API_KEY=old\n+API_KEY=supersecret"
texts = self._texts(DiffMessage(diff, file_path=".env"))
assert any("may contain credentials" in text for text in texts)
assert all("supersecret" not in text for text in texts)
def test_regular_file_diff_is_rendered(self) -> None:
diff = "@@ -1 +1 @@\n-print('a')\n+print('b')"
texts = self._texts(DiffMessage(diff, file_path="main.py"))
assert all("may contain credentials" not in text for text in texts)
assert any("print('b')" in text for text in texts)
def test_empty_file_path_renders_diff(self) -> None:
"""An unknown (empty) path renders normally rather than falsely hiding.
Callers always populate `file_path`, so a blank path means "unknown",
not "credential"; it must not surface the redaction notice.
"""
diff = "@@ -1 +1 @@\n-print('a')\n+print('b')"
texts = self._texts(DiffMessage(diff, file_path=""))
assert all("may contain credentials" not in text for text in texts)
assert any("print('b')" in text for text in texts)
class TestToolCallMessageDuration:
"""Tests for the post-run duration shown on `execute` tool calls."""
@@ -1,12 +1,29 @@
from pathlib import Path
import pytest
from textual.content import Content
from textual.widget import Widget
from textual.widgets import Markdown
from deepagents_code.tui.widgets.tool_renderers import get_renderer
from deepagents_code.tui.widgets.tool_widgets import (
_MAX_LINES,
EditFileApprovalWidget,
GenericApprovalWidget,
WriteFileApprovalWidget,
)
_CREDENTIAL_NOTICE_FRAGMENT = "may contain credentials"
def _widget_texts(widgets: list[Widget]) -> list[str]:
"""Return the plain text rendered by each widget, ignoring styles."""
texts: list[str] = []
for widget in widgets:
rendered = widget.render()
texts.append(rendered.plain if isinstance(rendered, Content) else str(rendered))
return texts
def test_write_renderer_formats_non_string_content() -> None:
widget_class, data = get_renderer("write_file").get_approval_widget(
@@ -36,6 +53,67 @@ def test_write_widget_formats_non_string_content() -> None:
assert len(widgets) == 3
@pytest.mark.parametrize(
"file_path",
[".env", "/home/user/project/.env", "config/.env.local"],
)
def test_write_widget_redacts_credential_file_content(file_path: str) -> None:
widgets = list(
WriteFileApprovalWidget(
{
"file_path": file_path,
"content": "SECRET_KEY=supersecret",
"file_extension": "text",
}
).compose()
)
assert any(_CREDENTIAL_NOTICE_FRAGMENT in text for text in _widget_texts(widgets))
# Content is rendered via `Markdown`, whose `render()` yields the widget
# name rather than its source — so `_widget_texts` cannot see a leak there
# and a substring check alone would pass even if the secret slipped
# through. Guard structurally: the credential branch must emit no
# `Markdown` child at all.
assert not any(isinstance(widget, Markdown) for widget in widgets)
def test_write_widget_renders_regular_file_via_markdown() -> None:
"""Positive control for the credential redaction test.
A non-credential file must use the `Markdown` branch; without this, the
"no `Markdown` child" assertion above could pass vacuously if the widget
stopped using `Markdown` for everything.
"""
widgets = list(
WriteFileApprovalWidget(
{
"file_path": "main.py",
"content": "print('hi')",
"file_extension": "python",
}
).compose()
)
assert any(isinstance(widget, Markdown) for widget in widgets)
def test_write_widget_redacts_large_credential_file() -> None:
"""A credential file longer than the truncation limit must not leak.
The redaction check must short-circuit before the truncation branch,
which would otherwise render the first `_MAX_LINES` lines via `Markdown`.
"""
content = "\n".join(f"SECRET_{i}=value{i}" for i in range(_MAX_LINES + 20))
widgets = list(
WriteFileApprovalWidget(
{"file_path": ".env", "content": content, "file_extension": "text"}
).compose()
)
assert any(_CREDENTIAL_NOTICE_FRAGMENT in text for text in _widget_texts(widgets))
assert not any(isinstance(widget, Markdown) for widget in widgets)
def test_edit_renderer_formats_non_string_content() -> None:
widget_class, data = get_renderer("edit_file").get_approval_widget(
{
@@ -66,6 +144,23 @@ def test_edit_widget_formats_non_string_content() -> None:
assert widgets
def test_edit_widget_redacts_credential_file_diff() -> None:
widgets = list(
EditFileApprovalWidget(
{
"file_path": "config/.env.local",
"diff_lines": ["+API_TOKEN=leaked"],
"old_string": "",
"new_string": "API_TOKEN=leaked",
}
).compose()
)
texts = _widget_texts(widgets)
assert any(_CREDENTIAL_NOTICE_FRAGMENT in text for text in texts)
assert all("leaked" not in text for text in texts)
def test_delete_renderer_shows_removed_file_diff(tmp_path: Path) -> None:
target = tmp_path / "old.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")
@@ -80,6 +175,25 @@ def test_delete_renderer_shows_removed_file_diff(tmp_path: Path) -> None:
assert "-beta" in data["diff_lines"]
def test_delete_widget_redacts_credential_file(tmp_path: Path) -> None:
"""Deleting a credential file must not show its removed contents.
Delete routes through `EditFileApprovalWidget` with the display-formatted
path, so this pins that the sensitivity check still fires on that path.
"""
target = tmp_path / ".env"
target.write_text("API_KEY=supersecret\n", encoding="utf-8")
widget_class, data = get_renderer("delete").get_approval_widget(
{"file_path": str(target)}
)
widgets = list(widget_class(data).compose())
texts = _widget_texts(widgets)
assert any(_CREDENTIAL_NOTICE_FRAGMENT in text for text in texts)
assert all("supersecret" not in text for text in texts)
def test_delete_renderer_flags_directories_without_diff(tmp_path: Path) -> None:
target = tmp_path / "subdir"
target.mkdir()