package vip.mate.tool.browser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.tool.guard.model.GuardDecision;
import vip.mate.tool.guard.model.GuardSeverity;
import vip.mate.tool.guard.model.ToolGuardAuditLogEntity;
import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper;
import java.net.URI;
import java.util.List;
/**
* Privacy guard for browser sessions attached to a user's own logged-in browser.
*
*
When the browser tool connects to a Chrome the user is running themselves
* (via the DevTools Protocol, process not spawned by us), that browser may have
* banking, webmail or internal-admin tabs open with live sessions. Reading such
* a page in full — screenshot, arbitrary JS eval, or a text/accessibility dump —
* would funnel private content into the model and possibly into persisted
* transcripts. This guard classifies the current page and refuses those
* content-reading actions on pages that look sensitive, while leaving plain
* navigation untouched. It never applies to headless or self-spawned browsers.
*
*
Classification precedence: configured trusted hosts (always safe) →
* configured sensitive hosts → a built-in keyword heuristic over host + path.
* Every block is overridable by adding the host to
* {@code mateclaw.browser.privacy.trusted-hosts}.
*/
@Slf4j
@Component
public class BrowserPrivacyGuard {
/** Keyword signals that a page handles money, identity, or privileged access. */
private static final List SENSITIVE_SIGNALS = List.of(
"bank", "banking", "pay", "payment", "wallet", "checkout", "billing", "invoice",
"mail", "webmail", "signin", "login", "logon", "account", "admin", "console",
"secure", "oauth", "authorize", "password", "passport", "identity");
private final BrowserProperties properties;
private final ToolGuardAuditLogMapper auditMapper;
public BrowserPrivacyGuard(BrowserProperties properties, ToolGuardAuditLogMapper auditMapper) {
this.properties = properties;
this.auditMapper = auditMapper;
}
/**
* Decide whether a content-reading action must be refused. Returns a
* human-readable block reason, or {@code null} when the action may proceed.
*
* @param userManagedBrowser true only when attached to a Chrome the user runs themselves
* @param url the current page URL
* @param action the action being attempted (screenshot / eval / snapshot)
*/
public String blockReason(boolean userManagedBrowser, String url, String action) {
if (!properties.getPrivacy().isEnabled() || !userManagedBrowser) {
return null;
}
if (!isSensitive(url)) {
return null;
}
return "Refusing action=" + action + " on what looks like a sensitive page (" + safeHost(url)
+ ") inside your own logged-in browser, to avoid exposing private content to the"
+ " model. Plain navigation is still allowed. If this page is safe to read, add its"
+ " host to mateclaw.browser.privacy.trusted-hosts.";
}
/** True when the URL matches a configured/heuristic sensitive signal and is not trusted. */
public boolean isSensitive(String url) {
if (url == null || url.isBlank()) {
return false;
}
String host;
String path;
try {
URI uri = URI.create(url);
host = uri.getHost() == null ? "" : uri.getHost().toLowerCase();
path = uri.getPath() == null ? "" : uri.getPath().toLowerCase();
} catch (IllegalArgumentException e) {
return false;
}
if (host.isEmpty()) {
return false;
}
if (hostMatches(host, properties.getPrivacy().getTrustedHosts())) {
return false;
}
if (hostMatches(host, properties.getPrivacy().getSensitiveHosts())) {
return true;
}
String hostAndPath = host + path;
for (String sig : SENSITIVE_SIGNALS) {
if (hostAndPath.contains(sig)) {
return true;
}
}
return false;
}
/** Record a blocked read into the shared tool-guard audit log so it shows up in the audit panel. */
public void audit(String conversationId, String action, String url, String reason) {
try {
ToolGuardAuditLogEntity entity = new ToolGuardAuditLogEntity();
entity.setConversationId(conversationId);
entity.setToolName("browser_use");
entity.setDecision(GuardDecision.BLOCK.name());
entity.setMaxSeverity(GuardSeverity.HIGH.name());
entity.setToolParamsJson("{\"action\":\"" + action + "\",\"host\":\"" + safeHost(url) + "\"}");
entity.setFindingsJson("[{\"type\":\"sensitive-page\",\"reason\":\""
+ reason.replace("\"", "'") + "\"}]");
auditMapper.insert(entity);
} catch (Exception e) {
log.warn("[BrowserPrivacyGuard] Failed to record audit entry: {}", e.getMessage());
}
}
private static boolean hostMatches(String host, List patterns) {
if (patterns == null) {
return false;
}
for (String p : patterns) {
if (p == null || p.isBlank()) {
continue;
}
String pat = p.trim().toLowerCase();
if (host.equals(pat) || host.endsWith("." + pat)) {
return true;
}
}
return false;
}
private static String safeHost(String url) {
try {
String h = URI.create(url).getHost();
return h == null ? "unknown" : h;
} catch (Exception e) {
return "unknown";
}
}
}