Files
hermes-agent/tools/browser_tool_snapshot.py
Teknium 5aa9e7f033 fix(vault): independent-review findings — vendor contracts, profile scope, transport, target binding
Bitwarden unlock now uses the CLI's documented non-interactive channel:
`bw unlock --raw --nointeraction --passwordenv VAR`, VAR set on the child
environment only (bw 2026.x rejects a piped password with "Master password
is required"). Verified against the real published binary.

Manager session tokens are keyed by (profile home, backend): a Desktop
gateway hosting several profiles can no longer reuse or lock another
profile's session. Status probes (`vault.sources`, is_unlocked) no longer
refresh the idle TTL; only real manager calls do. Gateway session teardown
locks the profile's managers (a per-session unlock ends with the session).

1Password service-account token comes from the profile-scoped secret store
(get_secret), not ambient os.environ.

`vault.source.set` no longer references a module constant (bind_module
rebinding dropped it → NameError on every Settings toggle).

Fill target binding: inspection stamps each input with a per-inspection
slot attribute; the fill resolves by stamp and requires type=password, then
strips every stamp. A DOM reflow between inspect and fill can no longer
redirect the password into a text field (reproduced in real Chrome before,
0 filled after).

Redaction boundary: no 4-char floor, CR/LF-normalized form registered
(what a text input actually stores), JSON object KEYS scrubbed in both
browser redactors; longest value first. Docs now state the real trust
model: accidental-disclosure protection, not an execution sandbox.

Desktop: the mid-turn card sends the master password through the owning
session's socket (requestForOwnedSession), never the ambient foreground
gateway; `vault.unlock.expire` clears a stale card; Settings keeps the
master password out of react-query mutation variables (ref consumed by the
mutationFn). One renderer invariant test for the routing.
2026-09-10 10:35:07 -07:00

126 lines
5.2 KiB
Python

"""Snapshot post-processing for the browser tools: truncate-and-store of oversized
accessibility trees, model-boundary secret redaction, screenshot-path recovery.
Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
"""
import re
from typing import Any, Optional
from tools.browser_tool_origin import origin as _bt
_SCREENSHOT_PATH_PATTERNS = (
r"Screenshot saved to ['\"](?P<path>/[^'\"]+?\.png)['\"]",
r"Screenshot saved to (?P<path>/\S+?\.png)(?:\s|$)",
r"(?P<path>/\S+?\.png)(?:\s|$)",
)
def _extract_screenshot_path_from_text(text: str) -> Optional[str]:
"""Extract a screenshot file path from agent-browser human-readable output."""
if not text:
return None
for pattern in _SCREENSHOT_PATH_PATTERNS:
match = re.search(pattern, text)
if match:
path = match.group("path").strip().strip("'\"")
if path:
return path
return None
def _store_full_snapshot(snapshot_text: str) -> Optional[str]:
"""Write a full snapshot to cache/web and return its path (None on failure — best-effort).
Mirrors ``web_tools._store_full_text``: cache/web is mounted read-only into
remote backends, so read_file can page through the complete tree on any
backend. The stored copy is force-redacted (page-rendered secrets must not
hit disk unmasked) and named by content hash so identical snapshots dedupe.
"""
try:
import hashlib
from hermes_constants import get_hermes_dir
from agent.redact import redact_sensitive_text
content = redact_sensitive_text(snapshot_text, force=True)
if len(content) > _bt.MAX_STORED_SNAPSHOT_CHARS:
content = (
content[:_bt.MAX_STORED_SNAPSHOT_CHARS]
+ f"\n\n[... stored copy truncated at {_bt.MAX_STORED_SNAPSHOT_CHARS:,} chars "
f"of {len(content):,} ...]"
)
from tools.spill_safety import ensure_spill_dir, write_text_exclusive
cache_dir = get_hermes_dir("cache/web", "web_cache")
ensure_spill_dir(cache_dir, private=False)
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10]
path = cache_dir / f"browser-snapshot-{digest}.txt"
# Deterministic filename in a well-known dir: refuse symlinks (lstat-unlink +
# exclusive create); same-content re-snapshots legitimately overwrite. Not
# private: cache/web is bind-mounted into remote backends' container UID.
write_text_exclusive(path, content, private=False, overwrite=True)
return str(path)
except Exception as exc: # noqa: BLE001
_bt.logger.debug("Failed to store full browser snapshot: %s", exc)
return None
def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> str:
"""Truncate a snapshot at line boundaries (never mid-element) to ``max_chars``.
Defaults to ``browser.snapshot_threshold``. The full snapshot is stored to
cache/web and the appended note tells the agent how to page through it
with read_file — element refs beyond the cut are in the file, not lost.
"""
if max_chars is None:
max_chars = _bt.get_browser_snapshot_threshold()
if len(snapshot_text) <= max_chars:
return snapshot_text
stored_path = _store_full_snapshot(snapshot_text)
if stored_path:
# Agent-visible path: read_file runs inside the active backend (#72389).
from tools.credential_files import to_agent_visible_cache_path
stored_path = to_agent_visible_cache_path(stored_path)
lines = snapshot_text.split('\n')
result: list[str] = []
chars = 0
# Reserve space for the truncation note (the stored-path variant is the
# longer of the two). Clamp so tiny max_chars values still keep content.
reserve = min(110 + len(stored_path or ""), max_chars // 2)
for line in lines:
if chars + len(line) + 1 > max_chars - reserve:
break
result.append(line)
chars += len(line) + 1
remaining = len(lines) - len(result)
if remaining > 0:
if stored_path:
next_line = len(result) + 1
result.append(
f'\n[... {remaining} more lines truncated — full snapshot: '
f'read_file path="{stored_path}" offset={next_line} limit=200]'
)
else:
result.append(f'\n[... {remaining} more lines truncated, use browser_snapshot for full content]')
return '\n'.join(result)
def _redact_browser_output(value: Any) -> Any:
"""Force-redact secrets in browser-originated data (snapshots, console, eval
results can carry page-rendered keys/cookies/tokens). Tool output is a model
boundary, so this applies even when global log redaction is disabled."""
from agent.redact import redact_sensitive_text
if isinstance(value, str):
return redact_sensitive_text(value, force=True)
if isinstance(value, list):
return [_redact_browser_output(item) for item in value]
if isinstance(value, tuple):
return tuple(_redact_browser_output(item) for item in value)
if isinstance(value, dict):
return {_redact_browser_output(key): _redact_browser_output(item) for key, item in value.items()}
return value