mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user