Repository: browser-use/browser-use

Issue:
Inconsistent behavior when sensitive data values are present in task but not in sensitive_data dict
### Bug Description

When a task contains substitution strings for sensitive values, different behavior occurs depending on whether all the sensitive values are given, one is missing, or multiple are missing.

Consider a prompt that looks like the following:
`Enter x_sens1, x_sens2, x_sens3, x_sens4, and x_sens5 into the search bar, all separated by a comma. There is no need to execute the search.`

If `x_sens2` is missing from the sensitive_data dict, the agent will substitute `<secret>x_sens2</secret>` along with the values from the sensitive_data dict for the respective substitution strings.

If multiple keys are missing from the sensitive_data dict (e.g. `x_sens2` and `x_sens4` are missing), the sensitive_data kwarg will be silently ignored, resulting in a task which contains all the substitution strings and not the sensitive values.

Ideally a warning or error should be thrown when substitution string keys are missing from the sensitive_data dict. At a minimum the dict should not be silently ignored.

### Reproduction Steps

1. Create a task that contains substitution strings that correspond to the keys in the sensitive_data dict
2. Run the agent
3. Everything works as expected

```
INFO     [agent] 🚀 Starting task: Enter x_sens1, x_sens2, x_sens3, x_sens4, and x_sens5 into the search bar, all separated by a comma. There is no need to execute the search.
INFO     [browser] Reusing existing Chrome instance
INFO     [controller] 🔗  Opened new tab with https://www.google.com
INFO     [agent] 📍 Step 1
INFO     [agent] 👍 Eval: Success - A new tab was opened with Google search page.
INFO     [agent] 🧠 Memory: A new tab is open with Google.com. I need to enter the placeholders into the search bar.
INFO     [agent] 🎯 Next goal: Enter the placeholders 'x_sens1, x_sens2, x_sens3, x_sens4, x_sens5' into the search bar.
INFO     [agent] 🛠️  Action 1/1: {"input_text":{"index":9,"text":"<secret>x_sens1</secret>, <secret>x_sens2</secret>, <secret>x_sens3</secret>, <secret>x_sens4</secret>, <secret>x_sens5</secret>"}}
INFO     [controller] ⌨️  Input test1, test2, test3, test4, test5 into index 9
```


1. Use the same task from the first run
2. Delete one of the keys from the sensitive_data dict
3. Run the agent
4. One of the substitution strings is wrapped in <secret> tags and injected instead of a value

```
INFO     [agent] 🚀 Starting task: Enter x_sens1, x_sens2, x_sens3, x_sens4, and x_sens5 into the search bar, all separated by a comma. There is no need to execute the search.
INFO     [controller] 🔗  Opened new tab with https://www.google.com
INFO     [agent] 📍 Step 1
INFO     [agent] 👍 Eval: Success - A new tab with Google was opened successfully.
INFO     [agent] 🧠 Memory: Opened Google homepage. Ready to input search query.
INFO     [agent] 🎯 Next goal: Input the search terms into Google search bar.
INFO     [agent] 🛠️  Action 1/1: {"input_text":{"index":9,"text":"<secret>x_sens1</secret>, <secret>x_sens2</secret>, <secret>x_sens3</secret>, <secret>x_sens4</secret>, <secret>x_sens5</secret>","xpath":null}}
INFO     [controller] ⌨️  Input test1, <secret>x_sens2</secret>, test3, test4, test5 into index 9
```

1. Use the same task from the first run
2. Delete multiple keys from the sensitive_data dict
3. Run the agent
4. All of the sensitive_data dict is ignored

```
INFO     [browser_use] BrowserUse logging setup complete with level info
INFO     [root] Anonymized telemetry enabled. See https://docs.browser-use.com/development/telemetry for more information.
INFO     [agent] 🚀 Starting task: Enter x_sens1, x_sens2, x_sens3, x_sens4, and x_sens5 into the search bar, all separated by a comma. There is no need to execute the search.
INFO     [controller] 🔗  Opened new tab with https://www.google.com
INFO     [agent] 📍 Step 1
INFO     [agent] 👍 Eval: Success - Google search page is open and ready for input.
INFO     [agent] 🧠 Memory: Google search page is open. Need to enter 'x_sens1, x_sens2, x_sens3, x_sens4, x_sens5' into the search bar.
INFO     [agent] 🎯 Next goal: Input the given text into the search bar.
INFO     [agent] 🛠️  Action 1/1: {"input_text":{"index":9,"text":"x_sens1, x_sens2, x_sens3, x_sens4, x_sens5"}}
INFO     [controller] ⌨️  Input x_sens1, x_sens2, x_sens3, x_sens4, x_sens5 into index 9
```

### Code Sample

```python
task = 'Enter x_sens1, x_sens2, x_sens3, x_sens4, and x_sens5 into the search bar, all separated by a comma. There is no need to execute the search.'

sensitive_data = {'x_sens1': 'test1', 'x_sens3': 'test3', 'x_sens5': 'test5'}

async def main():

    browser = Browser(
        config=BrowserConfig(
            # NOTE: you need to close your chrome browser - so that this can open your browser in debug mode
            chrome_instance_path='/usr/bin/google-chrome',
        )
    )


    initial_actions = [
        {'open_tab': {'url': 'https://www.google.com'}},
    ]
    agent = Agent(
        initial_actions=initial_actions,
        task=task,
        llm=ChatOpenAI(model='gpt-4o'),
        browser=browser,
        sensitive_data=sensitive_data,
    )

    history = await agent.run()
```

### Version

0.1.36

### LLM Model

GPT-4o

### Operating System

Pop!_OS 22.04 LTS

### Relevant Log Output

```shell

```


Discussion:

Patch:
diff --git a/browser_use/agent/message_manager/service.py b/browser_use/agent/message_manager/service.py
index c54669e9a..5307c39d8 100644
--- a/browser_use/agent/message_manager/service.py
+++ b/browser_use/agent/message_manager/service.py
@@ -62,7 +62,7 @@ def _init_messages(self) -> None:
 
 		if self.settings.sensitive_data:
 			info = f'Here are placeholders for sensitive data: {list(self.settings.sensitive_data.keys())}'
-			info += 'To use them, write <secret>the placeholder name</secret>'
+			info += '\nTo use them, write <secret>the placeholder name</secret>'
 			info_message = HumanMessage(content=info)
 			self._add_message_with_tokens(info_message, message_type='init')
 
@@ -216,10 +216,19 @@ def _filter_sensitive_data(self, message: BaseMessage) -> BaseMessage:
 		def replace_sensitive(value: str) -> str:
 			if not self.settings.sensitive_data:
 				return value
-			for key, val in self.settings.sensitive_data.items():
-				if not val:
-					continue
+
+			# Create a dictionary with all key-value pairs from sensitive_data where value is not None or empty
+			valid_sensitive_data = {k: v for k, v in self.settings.sensitive_data.items() if v}
+
+			# If there are no valid sensitive data entries, just return the original value
+			if not valid_sensitive_data:
+				logger.warning('No valid entries found in sensitive_data dictionary')
+				return value
+
+			# Replace all valid sensitive data values with their placeholder tags
+			for key, val in valid_sensitive_data.items():
 				value = value.replace(val, f'<secret>{key}</secret>')
+
 			return value
 
 		if isinstance(message.content, str):
diff --git a/browser_use/controller/registry/service.py b/browser_use/controller/registry/service.py
index 4311f7004..5f86642f2 100644
--- a/browser_use/controller/registry/service.py
+++ b/browser_use/controller/registry/service.py
@@ -154,16 +154,27 @@ def _replace_sensitive_data(self, params: BaseModel, sensitive_data: dict[str, s
 		"""Replaces the sensitive data in the params"""
 		# if there are any str with <secret>placeholder</secret> in the params, replace them with the actual value from sensitive_data
 
+		import logging
 		import re
 
+		logger = logging.getLogger(__name__)
 		secret_pattern = re.compile(r'<secret>(.*?)</secret>')
 
+		# Set to track all missing placeholders across the full object
+		all_missing_placeholders = set()
+
 		def replace_secrets(value):
 			if isinstance(value, str):
 				matches = secret_pattern.findall(value)
+
 				for placeholder in matches:
-					if placeholder in sensitive_data:
+					if placeholder in sensitive_data and sensitive_data[placeholder]:
 						value = value.replace(f'<secret>{placeholder}</secret>', sensitive_data[placeholder])
+					else:
+						# Keep track of missing placeholders
+						all_missing_placeholders.add(placeholder)
+						# Don't replace the tag, keep it as is
+
 				return value
 			elif isinstance(value, dict):
 				return {k: replace_secrets(v) for k, v in value.items()}
@@ -173,6 +184,11 @@ def replace_secrets(value):
 
 		params_dump = params.model_dump()
 		processed_params = replace_secrets(params_dump)
+
+		# Log a warning if any placeholders are missing
+		if all_missing_placeholders:
+			logger.warning(f'Missing or empty keys in sensitive_data dictionary: {", ".join(all_missing_placeholders)}')
+
 		return type(params).model_validate(processed_params)
 
 	# @time_execution_sync('--create_action_model')
diff --git a/docs/customize/sensitive-data.mdx b/docs/customize/sensitive-data.mdx
index 3bb5f6c66..92c86509e 100644
--- a/docs/customize/sensitive-data.mdx
+++ b/docs/customize/sensitive-data.mdx
@@ -45,6 +45,14 @@ In this example:
 2. When the model wants to use your password it outputs x_password - and we replace it with the actual value.
 3. When your password is visible on the current page, we replace it in the LLM input - so that the model never has it in its state.
 
+### Missing or Empty Values
+
+When working with sensitive data, keep these details in mind:
+
+- If a key referenced by the model (`<secret>key_name</secret>`) is missing from your `sensitive_data` dictionary, a warning will be logged but the substitution tag will be preserved.
+- If you provide an empty value for a key in the `sensitive_data` dictionary, it will be treated the same as a missing key.
+- The system will always attempt to process all valid substitutions, even if some keys are missing or empty.
+
 Warning: Vision models still see the image of the page - where the sensitive data might be visible.
 
 This approach ensures that sensitive information remains secure while still allowing the agent to perform tasks that require authentication.


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 71d369e57..cbc75c6ea 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -40,6 +40,7 @@ jobs:
         - functionality/hooks
         - test_controller
         - test_tab_management
+        - test_sensitive_data
     steps:
       - uses: actions/checkout@v4
       - uses: astral-sh/setup-uv@v6
diff --git a/tests/test_sensitive_data.py b/tests/test_sensitive_data.py
new file mode 100644
index 000000000..89f722bb0
--- /dev/null
+++ b/tests/test_sensitive_data.py
@@ -0,0 +1,91 @@
+import pytest
+from langchain_core.messages import HumanMessage, SystemMessage
+from pydantic import BaseModel, Field
+
+from browser_use.agent.message_manager.service import MessageManager, MessageManagerSettings
+from browser_use.agent.views import MessageManagerState
+from browser_use.controller.registry.service import Registry
+
+
+class SensitiveParams(BaseModel):
+	"""Test parameter model for sensitive data testing."""
+
+	text: str = Field(description='Text with sensitive data placeholders')
+
+
+@pytest.fixture
+def registry():
+	return Registry()
+
+
+@pytest.fixture
+def message_manager():
+	return MessageManager(
+		task='Test task',
+		system_message=SystemMessage(content='System message'),
+		settings=MessageManagerSettings(),
+		state=MessageManagerState(),
+	)
+
+
+def test_replace_sensitive_data_with_missing_keys(registry):
+	"""Test that _replace_sensitive_data handles missing keys gracefully"""
+	# Create a simple Pydantic model with sensitive data placeholders
+	params = SensitiveParams(text='Please enter <secret>username</secret> and <secret>password</secret>')
+
+	# Case 1: All keys present
+	sensitive_data = {'username': 'user123', 'password': 'pass456'}
+	result = registry._replace_sensitive_data(params, sensitive_data)
+	assert 'user123' in result.text
+	assert 'pass456' in result.text
+	# Both keys should be replaced
+
+	# Case 2: One key missing
+	sensitive_data = {'username': 'user123'}  # password is missing
+	result = registry._replace_sensitive_data(params, sensitive_data)
+	assert 'user123' in result.text
+	assert '<secret>password</secret>' in result.text
+	# Verify the behavior - username replaced, password kept as tag
+
+	# Case 3: Multiple keys missing
+	sensitive_data = {}  # both keys missing
+	result = registry._replace_sensitive_data(params, sensitive_data)
+	assert '<secret>username</secret>' in result.text
+	assert '<secret>password</secret>' in result.text
+	# Verify both tags are preserved when keys are missing
+
+	# Case 4: One key empty
+	sensitive_data = {'username': 'user123', 'password': ''}
+	result = registry._replace_sensitive_data(params, sensitive_data)
+	assert 'user123' in result.text
+	assert '<secret>password</secret>' in result.text
+	# Empty value should be treated the same as missing key
+
+
+def test_filter_sensitive_data(message_manager):
+	"""Test that _filter_sensitive_data handles all sensitive data scenarios correctly"""
+	# Set up a message with sensitive information
+	message = HumanMessage(content='My username is admin and password is secret123')
+
+	# Case 1: No sensitive data provided
+	message_manager.settings.sensitive_data = None
+	result = message_manager._filter_sensitive_data(message)
+	assert result.content == 'My username is admin and password is secret123'
+
+	# Case 2: All sensitive data is properly replaced
+	message_manager.settings.sensitive_data = {'username': 'admin', 'password': 'secret123'}
+	result = message_manager._filter_sensitive_data(message)
+	assert '<secret>username</secret>' in result.content
+	assert '<secret>password</secret>' in result.content
+
+	# Case 3: Make sure it works with nested content
+	nested_message = HumanMessage(content=[{'type': 'text', 'text': 'My username is admin and password is secret123'}])
+	result = message_manager._filter_sensitive_data(nested_message)
+	assert '<secret>username</secret>' in result.content[0]['text']
+	assert '<secret>password</secret>' in result.content[0]['text']
+
+	# Case 4: Test with empty values
+	message_manager.settings.sensitive_data = {'username': 'admin', 'password': ''}
+	result = message_manager._filter_sensitive_data(message)
+	assert '<secret>username</secret>' in result.content
+	# Only username should be replaced since password is empty