Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,12 +379,15 @@ async def connect(self) -> bool:
if not (bridge_dir / "node_modules").exists():
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
try:
# Read timeout from environment variable, default to 300 seconds (5 minutes)
# to accommodate slower systems like Unraid NAS
npm_install_timeout = int(os.environ.get("WHATSAPP_NPM_INSTALL_TIMEOUT", "300"))
install_result = subprocess.run(
["npm", "install", "--silent"],
cwd=str(bridge_dir),
capture_output=True,
text=True,
timeout=60,
timeout=npm_install_timeout,
)
if install_result.returncode != 0:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
Expand Down
29 changes: 27 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
_detect_tool_failure,
get_tool_emoji as _get_tool_emoji,
)
from agent.redact import redact_sensitive_text
from agent.trajectory import (
convert_scratchpad_to_think, has_incomplete_scratchpad,
save_trajectory as _save_trajectory_to_file,
Expand Down Expand Up @@ -3656,6 +3657,26 @@ def _clean_session_content(content: str) -> str:
content = re.sub(r'(</think>)\n+', r'\1\n', content)
return content.strip()

@staticmethod
def _redact_message_content(content):
"""Apply secret redaction to message content (string or list of parts)."""
if content is None:
return content
if isinstance(content, str):
return redact_sensitive_text(content)
if isinstance(content, list):
redacted = []
for part in content:
if isinstance(part, dict):
part = dict(part)
if isinstance(part.get("text"), str):
part["text"] = redact_sensitive_text(part["text"])
if isinstance(part.get("content"), str):
part["content"] = redact_sensitive_text(part["content"])
redacted.append(part)
return redacted
return content

def _save_session_log(self, messages: List[Dict[str, Any]] = None):
"""
Save the full raw session to a JSON file.
Expand All @@ -3673,12 +3694,16 @@ def _save_session_log(self, messages: List[Dict[str, Any]] = None):
return

try:
# Clean assistant content for session logs
# Clean assistant content for session logs, then redact credentials
cleaned = []
for msg in messages:
if msg.get("role") == "assistant" and msg.get("content"):
msg = dict(msg)
msg["content"] = self._clean_session_content(msg["content"])
# Redact secrets from all message content to prevent credential
# leaks in session_*.json files (issue #19845)
msg = dict(msg)
msg["content"] = self._redact_message_content(msg.get("content"))
cleaned.append(msg)

# Guard: never overwrite a larger session log with fewer messages.
Expand All @@ -3705,7 +3730,7 @@ def _save_session_log(self, messages: List[Dict[str, Any]] = None):
"platform": self.platform,
"session_start": self.session_start.isoformat(),
"last_updated": datetime.now().isoformat(),
"system_prompt": self._cached_system_prompt or "",
"system_prompt": redact_sensitive_text(self._cached_system_prompt or ""),
"tools": self.tools or [],
"message_count": len(cleaned),
"messages": cleaned,
Expand Down
88 changes: 88 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3641,6 +3641,94 @@ def test_uses_shared_atomic_json_helper(self, agent, tmp_path):
assert call_args.kwargs["default"] is str


class TestSaveSessionLogRedactsSecrets:
"""Regression: session_*.json must not contain plaintext credentials (#19845)."""

def test_redacts_api_key_in_message_content(self, agent, tmp_path):
agent.session_log_file = tmp_path / "session.json"
messages = [
{"role": "user", "content": "Hello"},
{
"role": "tool",
"content": 'Response: Authorization: Bearer sk-proj-abc123def456ghi789jkl012mno',
},
]

with patch("run_agent.atomic_json_write") as mock_write:
agent._save_session_log(messages)

payload = mock_write.call_args.args[1]
tool_msg = payload["messages"][1]
assert "sk-proj-abc123def456ghi789jkl012mno" not in tool_msg["content"]
assert "***" in tool_msg["content"] or "REDACTED" in tool_msg["content"]

def test_redacts_api_key_in_user_message(self, agent, tmp_path):
agent.session_log_file = tmp_path / "session.json"
messages = [
{"role": "user", "content": "My key is sk-ant-api03-abc123def456ghi789jkl012mno please use it"},
]

with patch("run_agent.atomic_json_write") as mock_write:
agent._save_session_log(messages)

payload = mock_write.call_args.args[1]
user_msg = payload["messages"][0]
assert "sk-ant-api03-abc123def456ghi789jkl012mno" not in user_msg["content"]

def test_redacts_system_prompt_credentials(self, agent, tmp_path):
agent.session_log_file = tmp_path / "session.json"
agent._cached_system_prompt = "Use key sk-proj-realkey1234567890123456 for API calls"
messages = [{"role": "user", "content": "test"}]

with patch("run_agent.atomic_json_write") as mock_write:
agent._save_session_log(messages)

payload = mock_write.call_args.args[1]
assert "sk-proj-realkey1234567890123456" not in payload["system_prompt"]

def test_redacts_list_type_content(self, agent, tmp_path):
agent.session_log_file = tmp_path / "session.json"
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Key: gsk_abc123def456ghi789jkl012mno"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
],
},
]

with patch("run_agent.atomic_json_write") as mock_write:
agent._save_session_log(messages)

payload = mock_write.call_args.args[1]
parts = payload["messages"][0]["content"]
text_part = parts[0]
assert "gsk_abc123def456ghi789jkl012mno" not in text_part["text"]
# Image URL should be preserved
assert parts[1]["image_url"]["url"].startswith("data:image")

def test_no_redaction_when_disabled(self, agent, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_REDACT_SECRETS", "0")
# Re-import to pick up the env change
import importlib
import agent.redact as redact_mod
old_val = redact_mod._REDACT_ENABLED
redact_mod._REDACT_ENABLED = False
try:
agent.session_log_file = tmp_path / "session.json"
messages = [{"role": "user", "content": "Key: sk-proj-test1234567890123456"}]

with patch("run_agent.atomic_json_write") as mock_write:
agent._save_session_log(messages)

payload = mock_write.call_args.args[1]
# When disabled, content passes through unchanged
assert "sk-proj-test1234567890123456" in payload["messages"][0]["content"]
finally:
redact_mod._REDACT_ENABLED = old_val


# ===================================================================
# Anthropic adapter integration fixes
# ===================================================================
Expand Down
37 changes: 37 additions & 0 deletions tests/tools/test_file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,4 +323,41 @@ def test_truncated_hint_with_nonzero_offset(self, mock_get):
assert "offset=100" in raw


class TestPatchSchema:
"""Tests for PATCH_SCHEMA to ensure required parameters are properly declared."""

def test_patch_schema_includes_all_required_params(self):
"""PATCH_SCHEMA should include all parameters that are conditionally required."""
from tools.file_tools import PATCH_SCHEMA

# Verify schema structure
assert "parameters" in PATCH_SCHEMA
assert "required" in PATCH_SCHEMA["parameters"]

# All parameters that are mode-specific should be in required list
required = PATCH_SCHEMA["parameters"]["required"]
assert "mode" in required
assert "path" in required
assert "old_string" in required
assert "new_string" in required
assert "patch" in required

# replace_all is optional (has default), so it should NOT be in required
assert "replace_all" not in required

def test_patch_schema_description_mentions_mode_specific_requirements(self):
"""PATCH_SCHEMA description should explain mode-specific requirements."""
from tools.file_tools import PATCH_SCHEMA

description = PATCH_SCHEMA.get("description", "")

# Description should mention mode-specific requirements
assert "mode-specific" in description.lower() or "IMPORTANT:" in description

# Should mention both modes
assert "mode='replace'" in description
assert "mode='patch'" in description




12 changes: 6 additions & 6 deletions tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,18 +908,18 @@ def _check_file_reqs():

PATCH_SCHEMA = {
"name": "patch",
"description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nReplace mode (default): find a unique string and replace it.\nPatch mode: apply V4A multi-file patches for bulk changes.",
"description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nIMPORTANT: Parameters are mode-specific:\n- For mode='replace': provide path, old_string, new_string (optionally replace_all)\n- For mode='patch': provide patch content",
"parameters": {
"type": "object",
"properties": {
"mode": {"type": "string", "enum": ["replace", "patch"], "description": "Edit mode: 'replace' for targeted find-and-replace, 'patch' for V4A multi-file patches", "default": "replace"},
"path": {"type": "string", "description": "File path to edit (required for 'replace' mode)"},
"old_string": {"type": "string", "description": "Text to find in the file (required for 'replace' mode). Must be unique in the file unless replace_all=true. Include enough surrounding context to ensure uniqueness."},
"new_string": {"type": "string", "description": "Replacement text (required for 'replace' mode). Can be empty string to delete the matched text."},
"path": {"type": "string", "description": "File path to edit (required when mode='replace')"},
"old_string": {"type": "string", "description": "Text to find in file (required when mode='replace'). Must be unique in file unless replace_all=true. Include enough surrounding context to ensure uniqueness."},
"new_string": {"type": "string", "description": "Replacement text (required when mode='replace'). Can be empty string to delete the matched text."},
"replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match (default: false)", "default": False},
"patch": {"type": "string", "description": "V4A format patch content (required for 'patch' mode). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"}
"patch": {"type": "string", "description": "V4A format patch content (required when mode='patch'). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"}
},
"required": ["mode"]
"required": ["mode", "path", "old_string", "new_string", "patch"]
}
}

Expand Down