Skip to content
3 changes: 3 additions & 0 deletions operator_use/cli/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def setup_logging(userdata_dir: Path, verbose: bool = False) -> None:
logging.basicConfig(level=logging.WARNING, format=fmt, datefmt=datefmt, handlers=handlers)
logging.getLogger("operator_use").setLevel(logging.INFO)

# Install credential masking so no secrets leak into log files or console
from operator_use.utils.log_filter import install_credential_masking
install_credential_masking()
Comment on lines +61 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. log_filter.py module missing 📎 Requirement gap ⚙ Maintainability

Compliance requires the credential-masking filter to be implemented at
operator_use/utils/log_filter.py, but this PR implements it in operator_use/utils/log_masking.py
and imports that module instead. This violates the required location/interface and can break
expected integrations that rely on the mandated path.
Agent Prompt
## Issue description
The credential-masking logging filter is not implemented at the required path `operator_use/utils/log_filter.py`.

## Issue Context
The PR currently implements `CredentialMaskingFilter` in `operator_use/utils/log_masking.py` and imports it from startup, but the compliance checklist mandates the dedicated module path.

## Fix Focus Areas
- operator_use/utils/log_masking.py[51-71]
- operator_use/cli/start.py[55-57]
- operator_use/utils/__init__.py[4-15]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


import operator_use
from operator_use.agent import Agent
Expand Down
3 changes: 2 additions & 1 deletion operator_use/tools/control_center.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import json
import logging
import os
import subprocess
import sys
from typing import Optional

Expand Down Expand Up @@ -125,7 +126,7 @@ async def _do_restart(graceful_fn=None) -> None:
``os._exit(75)`` which skips cleanup but guarantees the process terminates.
"""
global _requested_exit_code
os.system("cls" if os.name == "nt" else "clear")
subprocess.run(["cls"] if os.name == "nt" else ["clear"], check=False)
frames = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"]
for i in range(20):
sys.stdout.write(f"\r {frames[i % len(frames)]} Restarting Operator...")
Expand Down
12 changes: 11 additions & 1 deletion operator_use/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
"""Utils module."""

from operator_use.utils.helper import ensure_directory
from operator_use.utils.log_filter import (
CredentialMaskingFilter,
install_credential_masking,
mask_credentials,
)

__all__ = ["ensure_directory"]
__all__ = [
"CredentialMaskingFilter",
"ensure_directory",
"install_credential_masking",
"mask_credentials",
]
109 changes: 109 additions & 0 deletions operator_use/utils/log_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Credential masking for log output -- prevents secrets leaking into logs."""

import logging
import re


# Patterns that match common credential formats in log strings.
# Order matters: more specific patterns should come before general ones.
_MASK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
# URL DSN credentials: scheme://user:password@host or scheme://:password@host
(
re.compile(r"(://[^:@/\s]*:)[^@\s]+(@)"),
r"\1***REDACTED***\2",
),
# JWT-like strings (three base64url segments separated by dots)
(
re.compile(r"eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+"),
"***JWT_REDACTED***",
),
# Bearer token header values
(
re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*", re.IGNORECASE),
r"\1***REDACTED***",
),
# Provider-specific credential patterns
(re.compile(r"gsk_[A-Za-z0-9]{8,}", re.IGNORECASE), "gsk_***REDACTED***"),
(re.compile(r"AIza[A-Za-z0-9\-_]{8,}"), "AIza***REDACTED***"),
(re.compile(r"nvapi-[A-Za-z0-9\-_]{8,}", re.IGNORECASE), "nvapi-***REDACTED***"),
# API keys / tokens with common prefixes (sk-, pk-, api-, token-, key-)
# Allows multi-segment keys like sk-proj-abc12345678
# \b guards the word start; (?=...\d) requires at least one digit in the suffix
# to avoid matching infrastructure words like "api-gateway-endpoint"
(
re.compile(
r"\b(sk|pk|api|token|key)[-_](?=[A-Za-z0-9\-_]*\d)[A-Za-z0-9\-_]{8,}",
re.IGNORECASE,
),
r"\1-***REDACTED***",
),
Comment on lines +29 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. gsk_/aiza/nvapi- unmasked 📎 Requirement gap ⛨ Security

The masking regexes do not include required provider credential formats gsk_ (Groq), AIza
(Google), or nvapi- (NVIDIA), so these can appear unmasked in logs. This violates the requirement
to mask all specified provider patterns at all log levels.
Agent Prompt
## Issue description
Required provider credential patterns `gsk_`, `AIza`, and `nvapi-` are not masked by the current regex set.

## Issue Context
Current `_MASK_PATTERNS` includes `sk-` and other generic prefixes, but not these provider-specific formats mandated by compliance.

## Fix Focus Areas
- operator_use/utils/log_masking.py[7-41]
- tests/test_log_masking.py[12-66]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

# Authorization / x-api-key / x-auth-token headers
(
re.compile(
r"(authorization|x-api-key|x-auth-token)\s*[:=]\s*\S+", re.IGNORECASE
),
r"\1: ***REDACTED***",
),
# password= / secret= / token= / api_key= patterns in query strings or log lines
(
re.compile(
r"(password|secret|passwd|pwd|token|api_key|apikey)\s*[=:]\s*\S+",
re.IGNORECASE,
),
r"\1=***REDACTED***",
),
# Generic high-entropy secrets: key=value or key: value where value is 32+ alphanum chars
(
re.compile(r"(\b\w+\b\s*[=:]\s*)([A-Za-z0-9_\-]{32,})"),
r"\1***REDACTED***",
),
]
Comment on lines +7 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Missing 32+ token masking 📎 Requirement gap ⛨ Security

The implementation does not mask generic high-entropy secrets matching [a-zA-Z0-9_-]{32,} in
key-value contexts, allowing such values to be logged unredacted. This fails the generic
credential-leakage mitigation requirement.
Agent Prompt
## Issue description
Generic high-entropy secrets (32+ chars) in key-value contexts are not masked.

## Issue Context
Compliance requires masking for `[a-zA-Z0-9_-]{32,}` when logged as `key=value` or `key: value` even if the key name is not in a predefined list.

## Fix Focus Areas
- operator_use/utils/log_masking.py[7-41]
- tests/test_log_masking.py[12-66]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



def mask_credentials(text: str) -> str:
"""Apply all credential masking patterns to a string."""
for pattern, replacement in _MASK_PATTERNS:
text = pattern.sub(replacement, text)
return text


class CredentialMaskingFilter(logging.Filter):
"""Logging filter that redacts credential patterns from all log records.

Uses record.getMessage() to render the final formatted message before masking,
then clears record.args so the formatter does not re-apply %-style substitution.
This avoids TypeError when log args include numeric placeholders (%d, %.2f).
"""

def filter(self, record: logging.LogRecord) -> bool:
# Render the message with its args first to preserve type semantics,
# then mask the rendered string. Clear args so the handler formatter
# does not re-format (which would re-expose the original values).
rendered = record.getMessage()
record.msg = mask_credentials(rendered)
record.args = ()
return True
Comment on lines +78 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Logging args type broken 🐞 Bug ≡ Correctness

CredentialMaskingFilter.filter() converts all tuple/dict log args to strings, which can raise
TypeError for existing %-style numeric formatting (e.g., %d, %.2f) during record formatting. This
can crash logging calls or drop log output in normal execution paths.
Agent Prompt
### Issue description
`CredentialMaskingFilter.filter()` currently masks `record.msg` and then coerces `record.args` values to `str` to mask them. This breaks %-style logging for numeric placeholders (e.g. `%d`, `%.2f`) because the formatting step expects numbers, not strings.

### Issue Context
The repo already logs with numeric placeholders (iteration counters, timings, pids), so this will be hit in normal usage.

### Fix approach (safe)
- Avoid changing the types inside `record.args`.
- Instead, compute the final formatted message first, then mask that string:
  - `rendered = record.getMessage()`
  - `masked = mask_credentials(rendered)`
  - Set `record.msg = masked` and `record.args = ()` so the formatter won’t re-apply `%` formatting.
- Optionally also consider masking exception text / stack info if present.
- Add/adjust tests to cover numeric placeholders (e.g., `logger.info('iter=%d', 3)` and `logger.info('took %.2f', 1.23)`) to ensure no exception is raised.

### Fix Focus Areas
- operator_use/utils/log_masking.py[54-63]
- tests/test_log_masking.py[76-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



def install_credential_masking() -> None:
"""Install credential masking on the root logger and all current handlers.

Attaches CredentialMaskingFilter both to the root logger and to every
handler on the root logger, ensuring records emitted via named loggers
(logging.getLogger(__name__)) are masked regardless of propagation path.

Must be called *after* all handlers have been added to the root logger
(e.g. at the end of setup_logging()). Handlers added after this call
will not automatically receive the filter.
"""
root_logger = logging.getLogger()
filter_instance = CredentialMaskingFilter()

# Add to root logger filters (catches records at the logger level)
if not any(isinstance(f, CredentialMaskingFilter) for f in root_logger.filters):
root_logger.addFilter(filter_instance)

# Also add to every handler on the root logger for belt-and-suspenders coverage
for handler in root_logger.handlers:
if not any(isinstance(f, CredentialMaskingFilter) for f in handler.filters):
handler.addFilter(CredentialMaskingFilter())
2 changes: 1 addition & 1 deletion tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ async def test_agent_run_with_tool_call_then_text(tmp_path):

# Register a simple echo tool
from pydantic import BaseModel
from operator_use.tools.service import Tool
from operator_use.agent.tools.service import Tool

class EchoParams(BaseModel):
message: str
Expand Down
35 changes: 18 additions & 17 deletions tests/test_browser_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ def test_enabled_plugin_returns_system_prompt():
prompt = plugin.get_system_prompt()
assert prompt is SYSTEM_PROMPT
assert "browser" in prompt.lower()
assert "<perception>" in prompt
assert "<tool_use>" in prompt
assert "<execution_principles>" in prompt
# Prompt is plain Markdown — assert on actual content, not old XML tags
assert "browser_task" in prompt
assert "Chrome" in prompt


# ---------------------------------------------------------------------------
Expand All @@ -38,7 +38,7 @@ def test_disabled_plugin_registers_no_tools():
plugin = BrowserPlugin(enabled=False)
registry = ToolRegistry()
plugin.register_tools(registry)
assert registry.get("browser") is None
assert registry.get("browser_task") is None


def test_enabled_plugin_registers_browser_tool():
Expand All @@ -47,7 +47,7 @@ def test_enabled_plugin_registers_browser_tool():
plugin.browser = MagicMock()
registry = ToolRegistry()
plugin.register_tools(registry)
assert registry.get("browser") is not None
assert registry.get("browser_task") is not None


def test_unregister_tools_removes_browser_tool():
Expand All @@ -57,11 +57,11 @@ def test_unregister_tools_removes_browser_tool():
registry = ToolRegistry()
plugin.register_tools(registry)
plugin.unregister_tools(registry)
assert registry.get("browser") is None
assert registry.get("browser_task") is None


# ---------------------------------------------------------------------------
# register_hooks — BEFORE_LLM_CALL gated on _enabled
# register_hooks — hooks NOT registered to main agent (subagent arch)
# ---------------------------------------------------------------------------


Expand All @@ -72,20 +72,20 @@ def test_disabled_plugin_registers_no_hooks():
assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL]


def test_enabled_plugin_registers_state_hook():
def test_enabled_plugin_does_not_register_state_hook_to_main_agent():
"""Hooks are intentionally not wired to main agent — subagent manages its own state."""
plugin = BrowserPlugin(enabled=False)
plugin._enabled = True
hooks = Hooks()
plugin.register_hooks(hooks)
assert plugin._state_hook in hooks._handlers[HookEvent.BEFORE_LLM_CALL]
assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL]


def test_unregister_hooks_removes_state_hook():
def test_unregister_hooks_is_safe_noop():
plugin = BrowserPlugin(enabled=False)
plugin._enabled = True
hooks = Hooks()
plugin.register_hooks(hooks)
plugin.unregister_hooks(hooks)
plugin.unregister_hooks(hooks) # must not raise
assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL]


Expand All @@ -99,7 +99,7 @@ def test_disabled_plugin_does_not_inject_prompt():
context = MagicMock()
plugin.attach_prompt(context)
context.register_plugin_prompt.assert_not_called()
assert plugin._context is context # reference still stored
assert plugin._context is context


def test_enabled_plugin_injects_prompt():
Expand All @@ -125,7 +125,8 @@ def test_detach_prompt_removes_injected_prompt():


@pytest.mark.asyncio
async def test_enable_registers_hooks_and_injects_prompt():
async def test_enable_injects_prompt_no_hooks():
"""enable() registers tools and injects prompt — hooks NOT wired to main agent."""
plugin = BrowserPlugin(enabled=False)
hooks = Hooks()
plugin.register_hooks(hooks)
Expand All @@ -135,12 +136,12 @@ async def test_enable_registers_hooks_and_injects_prompt():
await plugin.enable()

assert plugin._enabled is True
assert plugin._state_hook in hooks._handlers[HookEvent.BEFORE_LLM_CALL]
assert plugin._state_hook not in hooks._handlers[HookEvent.BEFORE_LLM_CALL]
context.register_plugin_prompt.assert_called_once_with(SYSTEM_PROMPT)


@pytest.mark.asyncio
async def test_disable_unregisters_hooks_and_removes_prompt():
async def test_disable_removes_prompt():
plugin = BrowserPlugin(enabled=False)
plugin._enabled = True
hooks = Hooks()
Expand Down Expand Up @@ -178,7 +179,7 @@ async def test_enable_then_disable_leaves_no_hooks():
async def test_state_hook_skips_when_no_browser_client():
plugin = BrowserPlugin(enabled=False)
plugin.browser = MagicMock()
plugin.browser._client = None # no active session
plugin.browser._client = None

ctx = MagicMock()
ctx.messages = []
Expand Down
Loading
Loading