diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml index 9ca2f27433e5..cea0109cb164 100644 --- a/.github/workflows/nix-lockfile-fix.yml +++ b/.github/workflows/nix-lockfile-fix.yml @@ -18,7 +18,7 @@ on: types: [edited] permissions: - contents: write + contents: read pull-requests: write concurrency: @@ -48,6 +48,9 @@ jobs: concurrency: group: auto-fix-main cancel-in-progress: true + permissions: + contents: write + pull-requests: write steps: - name: Generate GitHub App token id: app-token @@ -122,8 +125,9 @@ jobs: exit 1 # ── PR fix (manual / checkbox) ───────────────────────────────────── - # Existing behavior: run on manual dispatch OR when a task-list - # checkbox in the sticky lockfile-check comment flips from [ ] to [x]. + # Runs untrusted PR content as data only: the base-repository checkout owns + # the action and Nix code that receive secrets; the PR checkout never gets + # persisted credentials or Cachix credentials. fix: if: | github.event_name == 'workflow_dispatch' || @@ -133,14 +137,15 @@ jobs: && !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**')) runs-on: ubuntu-latest timeout-minutes: 25 + permissions: + contents: write + pull-requests: write steps: - name: Authorize & resolve PR id: resolve uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9 with: script: | - // 1. Verify the actor has write access — applies to both checkbox - // clicks and manual dispatch. const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, @@ -154,7 +159,6 @@ jobs: return; } - // 2. Resolve which ref to check out. let prNumber = ''; if (context.eventName === 'issue_comment') { prNumber = String(context.payload.issue.number); @@ -162,11 +166,13 @@ jobs: prNumber = context.payload.inputs.pr_number || ''; } + core.setOutput('trusted_ref', context.payload.repository.default_branch); if (!prNumber) { core.setOutput('ref', context.ref.replace(/^refs\/heads\//, '')); core.setOutput('repo', context.repo.repo); core.setOutput('owner', context.repo.owner); core.setOutput('pr', ''); + core.setOutput('head_sha', ''); return; } @@ -179,10 +185,8 @@ jobs: core.setOutput('repo', pr.head.repo.name); core.setOutput('owner', pr.head.repo.owner.login); core.setOutput('pr', String(pr.number)); + core.setOutput('head_sha', pr.head.sha); - # Wipe the sticky lockfile-check comment to a "running" state as soon - # as the job is authorized, so the user sees their click was picked up - # before the ~minute of nix build work. - name: Mark sticky as running if: steps.resolve.outputs.pr != '' uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3 @@ -194,34 +198,83 @@ jobs: Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - name: Checkout trusted workflow code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + repository: ${{ github.repository }} + ref: ${{ steps.resolve.outputs.trusted_ref }} + path: trusted + persist-credentials: false + + - name: Checkout target tree without credentials + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }} - ref: ${{ steps.resolve.outputs.ref }} - token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ steps.resolve.outputs.head_sha || steps.resolve.outputs.ref }} + path: target fetch-depth: 0 + persist-credentials: false + token: '' - - uses: ./.github/actions/nix-setup + - name: Overlay package manifests onto trusted checkout + shell: bash + run: | + set -euo pipefail + for f in \ + ui-tui/package.json \ + ui-tui/package-lock.json \ + web/package.json \ + web/package-lock.json + do + cp "target/$f" "trusted/$f" + done + + - uses: ./trusted/.github/actions/nix-setup with: cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: Apply lockfile hashes + - name: Apply lockfile hashes using trusted Nix code id: apply - run: nix run .#fix-lockfiles + working-directory: trusted + run: nix run .#fix-lockfiles -- --apply + + - name: Copy generated lockfile hashes + shell: bash + run: | + set -euo pipefail + cp trusted/nix/tui.nix target/nix/tui.nix + cp trusted/nix/web.nix target/nix/web.nix + if git -C target diff --quiet -- nix/tui.nix nix/web.nix; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + id: copy - name: Commit & push - if: steps.apply.outputs.changed == 'true' + if: steps.copy.outputs.changed == 'true' + working-directory: target shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_OWNER: ${{ steps.resolve.outputs.owner }} + TARGET_REPO: ${{ steps.resolve.outputs.repo }} + TARGET_REF: ${{ steps.resolve.outputs.ref }} run: | set -euo pipefail + unexpected="$(git diff --name-only | grep -Ev '^nix/(tui|web)\.nix$' || true)" + if [ -n "$unexpected" ]; then + echo "::error::Unexpected modified files: $unexpected" + exit 1 + fi git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add nix/tui.nix nix/web.nix git commit -m "fix(nix): refresh npm lockfile hashes" - git push + git push "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_OWNER}/${TARGET_REPO}.git" "HEAD:refs/heads/${TARGET_REF}" - name: Update sticky (applied) - if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != '' + if: steps.copy.outputs.changed == 'true' && steps.resolve.outputs.pr != '' uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3 with: header: nix-lockfile-check @@ -232,7 +285,7 @@ jobs: Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - name: Update sticky (already current) - if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != '' + if: steps.copy.outputs.changed == 'false' && steps.resolve.outputs.pr != '' uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3 with: header: nix-lockfile-check diff --git a/acp_adapter/server.py b/acp_adapter/server.py index c61bb80e471d..bfb1f899e5cb 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -73,6 +73,8 @@ from acp_adapter.permissions import make_approval_callback from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets from acp_adapter.tools import build_tool_complete, build_tool_start +from agent.file_safety import get_read_block_error +from agent.redact import redact_sensitive_text logger = logging.getLogger(__name__) @@ -208,7 +210,35 @@ def _format_resource_text( return f"{header}\nURI: {uri}\n\n{body}" -def _resource_link_to_parts(block: ResourceContentBlock) -> list[dict[str, Any]]: +def _resolve_acp_resource_path(path: Path, cwd: Path | None) -> tuple[Path | None, str | None]: + """Resolve an ACP file resource under the session cwd and read policy.""" + if cwd is None: + return None, "ACP file resources require an active session cwd." + + try: + root = cwd.expanduser().resolve() + candidate = path.expanduser() + if not candidate.is_absolute(): + candidate = root / candidate + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, ValueError) as exc: + return None, f"Access denied: file resource is outside the ACP session directory ({exc})." + + block_error = get_read_block_error(str(resolved)) + if block_error: + return None, block_error + + try: + if not resolved.is_file(): + return None, "Access denied: ACP file resource is not a regular file." + except OSError as exc: + return None, f"Access denied: could not inspect ACP file resource ({exc})." + + return resolved, None + + +def _resource_link_to_parts(block: ResourceContentBlock, *, cwd: Path | None = None) -> list[dict[str, Any]]: """Convert an ACP resource_link block to OpenAI content parts. Returns a list of {"type": "text", ...} and/or {"type": "image_url", ...} @@ -236,6 +266,15 @@ def _resource_link_to_parts(block: ResourceContentBlock) -> list[dict[str, Any]] ), }] + path, path_error = _resolve_acp_resource_path(path, cwd) + if path_error: + return [{ + "type": "text", + "text": _format_resource_text(uri=uri, name=name, title=title, body=f"[{path_error}]"), + }] + if path is None: + return [] + # Image files: emit a short text header + image_url data URL so vision # models can see the attachment instead of a "binary omitted" note. image_mime = mime_type if _is_image_resource(mime_type) else _guess_image_mime_from_path(path) @@ -288,6 +327,7 @@ def _resource_link_to_parts(block: ResourceContentBlock) -> list[dict[str, Any]] ), }] note = None + text = redact_sensitive_text(text, code_file=True) if size > _MAX_ACP_RESOURCE_BYTES: note = f"truncated to {_MAX_ACP_RESOURCE_BYTES} of {size} bytes" return [{ @@ -399,6 +439,8 @@ def _content_blocks_to_openai_user_content( | ResourceContentBlock | EmbeddedResourceContentBlock ], + *, + cwd: Path | None = None, ) -> str | list[dict[str, Any]]: """Convert ACP prompt blocks into a Hermes/OpenAI-compatible user content payload.""" parts: list[dict[str, Any]] = [] @@ -416,7 +458,7 @@ def _content_blocks_to_openai_user_content( parts.append(image_part) continue if isinstance(block, ResourceContentBlock): - resource_parts = _resource_link_to_parts(block) + resource_parts = _resource_link_to_parts(block, cwd=cwd) for part in resource_parts: parts.append(part) if part.get("type") == "text": @@ -1087,7 +1129,7 @@ async def prompt( return PromptResponse(stop_reason="refusal") user_text = _extract_text(prompt).strip() - user_content = _content_blocks_to_openai_user_content(prompt) + user_content = _content_blocks_to_openai_user_content(prompt, cwd=state.cwd) text_only_prompt = all(isinstance(block, TextContentBlock) for block in prompt) has_content = bool(user_text) or ( isinstance(user_content, list) and bool(user_content) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 938ffee22f13..f97c352e149d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -470,9 +470,55 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non from gateway.config import Platform, PlatformConfig from gateway.session import SessionSource, build_session_key -from hermes_constants import get_hermes_dir +from hermes_constants import get_hermes_dir, get_hermes_home + + +def _trusted_gateway_media_roots() -> tuple[Path, ...]: + """Return Hermes-managed directories allowed for model-emitted attachments.""" + home = get_hermes_home() + roots = [ + get_image_cache_dir(), + get_audio_cache_dir(), + get_video_cache_dir(), + get_document_cache_dir(), + get_hermes_dir("cache/screenshots", "browser_screenshots"), + get_hermes_dir("cache/vision", "temp_vision_images"), + home / "cache" / "images", + home / "cache" / "audio", + home / "cache" / "videos", + home / "cache" / "documents", + ] + resolved: list[Path] = [] + for root in roots: + try: + resolved.append(Path(root).expanduser().resolve()) + except OSError: + continue + return tuple(dict.fromkeys(resolved)) + + +def _is_trusted_gateway_media_path(path: str) -> bool: + """Return True when *path* is a regular file inside Hermes media caches.""" + try: + candidate = Path(path).expanduser().resolve() + if not candidate.is_file(): + return False + return any(candidate.is_relative_to(root) for root in _trusted_gateway_media_roots()) + except OSError: + return False +def _filter_trusted_gateway_media_paths(paths): + """Drop model-emitted local paths outside Hermes-managed media caches.""" + trusted = [] + for item in paths: + path = item[0] if isinstance(item, tuple) else item + if _is_trusted_gateway_media_path(path): + trusted.append(item) + else: + logger.warning("Blocked untrusted model-emitted media path: %s", path) + return trusted + GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = ( "Secure secret entry is not supported over messaging. " "Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually." @@ -2982,6 +3028,8 @@ async def _stop_typing_task() -> None: # Extract MEDIA: tags (from TTS tool) before other processing media_files, response = self.extract_media(response) + media_files = _filter_trusted_gateway_media_paths(media_files) + # Extract image URLs and send them as native platform attachments images, text_content = self.extract_images(response) # Strip any remaining internal directives from message body (fixes #1561) @@ -2994,6 +3042,7 @@ async def _stop_typing_task() -> None: # Auto-detect bare local file paths for native media delivery # (helps small models that don't use MEDIA: syntax) local_files, text_content = self.extract_local_files(text_content) + local_files = _filter_trusted_gateway_media_paths(local_files) if local_files: logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files)) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 38e58ffc46e9..9ec9ed2efd7d 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1130,6 +1130,20 @@ async def _handle_c2c_message( if not self._is_dm_allowed(user_openid): return + source = self.build_source( + chat_id=user_openid, + user_id=user_openid, + chat_type="dm", + ) + + is_authorized = True + if self.gateway_runner and hasattr(self.gateway_runner, "_is_user_authorized"): + try: + is_authorized = bool(self.gateway_runner._is_user_authorized(source)) + except Exception: + logger.warning("[%s] Failed to evaluate QQ DM authorization; treating as unauthorized", self._log_tag) + is_authorized = False + text = content attachments_raw = d.get("attachments") logger.info( @@ -1155,8 +1169,17 @@ async def _handle_c2c_message( _att.get("filename", ""), ) - # Process all attachments uniformly (images, voice, files) - att_result = await self._process_attachments(attachments_raw) + # Avoid fetching/processing attachments for unauthorized DM senders. + if not is_authorized: + att_result = { + "image_urls": [], + "image_media_types": [], + "voice_transcripts": [], + "attachment_info": "", + } + else: + # Process all attachments uniformly (images, voice, files) + att_result = await self._process_attachments(attachments_raw) image_urls = att_result["image_urls"] image_media_types = att_result["image_media_types"] voice_transcripts = att_result["voice_transcripts"] @@ -1195,11 +1218,7 @@ async def _handle_c2c_message( self._chat_type_map[user_openid] = "c2c" event = MessageEvent( - source=self.build_source( - chat_id=user_openid, - user_id=user_openid, - chat_type="dm", - ), + source=source, text=text, message_type=self._detect_message_type(image_urls, image_media_types), raw_message=d, diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 83aa93e94cb3..1be83bb939bc 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -590,24 +590,33 @@ def _validate_signature( self, request: "web.Request", body: bytes, secret: str ) -> bool: """Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256).""" + secret_value = secret.strip() + if ( + secret_value.startswith("${") + and secret_value.endswith("}") + and len(secret_value) > 3 + ): + logger.warning("[webhook] Refusing unresolved placeholder secret") + return False + # GitHub: X-Hub-Signature-256 = sha256= gh_sig = request.headers.get("X-Hub-Signature-256", "") if gh_sig: expected = "sha256=" + hmac.new( - secret.encode(), body, hashlib.sha256 + secret_value.encode(), body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(gh_sig, expected) # GitLab: X-Gitlab-Token = gl_token = request.headers.get("X-Gitlab-Token", "") if gl_token: - return hmac.compare_digest(gl_token, secret) + return hmac.compare_digest(gl_token, secret_value) # Generic: X-Webhook-Signature = generic_sig = request.headers.get("X-Webhook-Signature", "") if generic_sig: expected = hmac.new( - secret.encode(), body, hashlib.sha256 + secret_value.encode(), body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(generic_sig, expected) diff --git a/gateway/run.py b/gateway/run.py index 8c884307c1f4..62ceccbd4cd8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5461,6 +5461,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: # Check pairing store (always checked, regardless of allowlists) platform_name = source.platform.value if source.platform else "" auth_user_id = user_id + team_id = source.guild_id or "" if source.platform == Platform.WECOM_CALLBACK and source.chat_id: auth_user_id = source.chat_id pairing_check_ids = [auth_user_id] diff --git a/tests/acp_adapter/test_acp_images.py b/tests/acp_adapter/test_acp_images.py index 096741d87fe4..a0ffb6c1d3b8 100644 --- a/tests/acp_adapter/test_acp_images.py +++ b/tests/acp_adapter/test_acp_images.py @@ -49,7 +49,7 @@ def test_acp_resource_link_file_is_inlined_as_text(tmp_path): uri=attached.as_uri(), mimeType="text/markdown", ), - ]) + ], cwd=tmp_path) assert content == ( "Please read this file\n" @@ -106,7 +106,7 @@ def test_acp_resource_link_image_file_is_inlined_as_image_url(tmp_path): uri=attached.as_uri(), mimeType="image/png", ), - ]) + ], cwd=tmp_path) assert isinstance(content, list) # [user text, image header, image_url] @@ -129,7 +129,7 @@ def test_acp_resource_link_image_mime_inferred_from_suffix(tmp_path): name="pic.jpg", uri=attached.as_uri(), ), - ]) + ], cwd=tmp_path) assert isinstance(content, list) image_parts = [p for p in content if p.get("type") == "image_url"] @@ -157,3 +157,40 @@ def test_acp_embedded_blob_image_is_inlined_as_image_url(): "type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}, } + + +def test_acp_resource_link_outside_cwd_is_not_inlined(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside.env" + outside.write_text("ACP_SECRET_OUTSIDE_CWD=supersecret", encoding="utf-8") + + content = _content_blocks_to_openai_user_content([ + ResourceContentBlock( + type="resource_link", + name="outside.env", + uri=outside.as_uri(), + mimeType="text/plain", + ), + ], cwd=workspace) + + assert "ACP_SECRET_OUTSIDE_CWD" not in content + assert "Access denied" in content + + +def test_acp_resource_link_redacts_inlined_secret(tmp_path, monkeypatch): + monkeypatch.setattr("agent.redact._REDACT_ENABLED", True) + attached = tmp_path / "secret.env" + attached.write_text("OPENAI_API_KEY=sk-" + "a" * 48, encoding="utf-8") + + content = _content_blocks_to_openai_user_content([ + ResourceContentBlock( + type="resource_link", + name="secret.env", + uri=attached.as_uri(), + mimeType="text/plain", + ), + ], cwd=tmp_path) + + assert "sk-" + "a" * 48 not in content + assert "sk-aaa...aaaa" in content diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 23646545bfcd..e088357b6398 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -729,3 +729,35 @@ def test_http_proxy_falls_back_without_aiohttp_socks(self): assert sess_kw == {} assert req_kw == {"proxy": "http://proxy:8080"} + + +def test_filter_trusted_gateway_media_paths_blocks_outside_cache(tmp_path, monkeypatch): + from gateway.platforms import base as base_mod + + hermes_home = tmp_path / "hermes" + cache_dir = hermes_home / "cache" / "images" + cache_dir.mkdir(parents=True) + trusted = cache_dir / "ok.png" + trusted.write_bytes(b"image") + outside = tmp_path / "private.png" + outside.write_bytes(b"secret") + monkeypatch.setattr(base_mod, "get_hermes_home", lambda: hermes_home) + + result = base_mod._filter_trusted_gateway_media_paths([ + (str(trusted), False), + (str(outside), False), + ]) + + assert result == [(str(trusted), False)] + + +def test_filter_trusted_gateway_media_paths_blocks_bare_local_outside_cache(tmp_path, monkeypatch): + from gateway.platforms import base as base_mod + + hermes_home = tmp_path / "hermes" + (hermes_home / "cache" / "images").mkdir(parents=True) + outside = tmp_path / "private.pdf" + outside.write_bytes(b"secret") + monkeypatch.setattr(base_mod, "get_hermes_home", lambda: hermes_home) + + assert base_mod._filter_trusted_gateway_media_paths([str(outside)]) == [] diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index a773efe5e589..fc85d2c633f3 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -67,11 +67,19 @@ def _event(thread_id=None): ) +def _trusted_media_path(monkeypatch, tmp_path, name: str) -> str: + path = tmp_path / name + path.write_bytes(b"media") + monkeypatch.setattr("gateway.platforms.base._trusted_gateway_media_roots", lambda: (tmp_path.resolve(),)) + return str(path) + + @pytest.mark.asyncio -async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(): +async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(monkeypatch, tmp_path): adapter = _MediaRoutingAdapter() event = _event() - adapter._message_handler = AsyncMock(return_value="MEDIA:/tmp/speech.flac") + media_path = _trusted_media_path(monkeypatch, tmp_path, "speech.flac") + adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_path}") adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice")) adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc")) @@ -79,17 +87,18 @@ async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(): adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.flac", + file_path=media_path, metadata=None, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_sender(): +async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_sender(monkeypatch, tmp_path): adapter = _MediaRoutingAdapter() event = _event() - adapter._message_handler = AsyncMock(return_value="MEDIA:/tmp/speech.ogg") + media_path = _trusted_media_path(monkeypatch, tmp_path, "speech.ogg") + adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_path}") adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice")) adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc")) @@ -97,18 +106,19 @@ async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_ adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.ogg", + file_path=media_path, metadata=None, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender(): +async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender(monkeypatch, tmp_path): adapter = _MediaRoutingAdapter() event = _event() + media_path = _trusted_media_path(monkeypatch, tmp_path, "speech.ogg") adapter._message_handler = AsyncMock( - return_value="[[audio_as_voice]]\nMEDIA:/tmp/speech.ogg" + return_value=f"[[audio_as_voice]]\nMEDIA:{media_path}" ) adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice")) adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc")) @@ -117,15 +127,16 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_ adapter.send_voice.assert_awaited_once_with( chat_id="chat-1", - audio_path="/tmp/speech.ogg", + audio_path=media_path, metadata=None, ) adapter.send_document.assert_not_awaited() @pytest.mark.asyncio -async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(): +async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(monkeypatch, tmp_path): event = _event(thread_id="topic-1") + media_path = _trusted_media_path(monkeypatch, tmp_path, "speech.flac") adapter = SimpleNamespace( name="test", extract_media=BasePlatformAdapter.extract_media, @@ -139,22 +150,23 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen await GatewayRunner._deliver_media_from_response( _routing_self(), - "MEDIA:/tmp/speech.flac", + f"MEDIA:{media_path}", event, adapter, ) adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.flac", + file_path=media_path, metadata={"thread_id": "topic-1"}, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_document_sender(): +async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_document_sender(monkeypatch, tmp_path): event = _event(thread_id="topic-1") + media_path = _trusted_media_path(monkeypatch, tmp_path, "speech.ogg") adapter = SimpleNamespace( name="test", extract_media=BasePlatformAdapter.extract_media, @@ -168,24 +180,25 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc await GatewayRunner._deliver_media_from_response( _routing_self(), - "MEDIA:/tmp/speech.ogg", + f"MEDIA:{media_path}", event, adapter, ) adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.ogg", + file_path=media_path, metadata={"thread_id": "topic-1"}, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(): +async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(monkeypatch, tmp_path): """MP3 audio on Telegram must go through send_voice (which routes to sendAudio internally); Telegram accepts MP3 for the audio player.""" event = _event(thread_id="topic-1") + media_path = _trusted_media_path(monkeypatch, tmp_path, "speech.mp3") adapter = SimpleNamespace( name="test", extract_media=BasePlatformAdapter.extract_media, @@ -199,14 +212,14 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender( await GatewayRunner._deliver_media_from_response( _routing_self(), - "MEDIA:/tmp/speech.mp3", + f"MEDIA:{media_path}", event, adapter, ) adapter.send_voice.assert_awaited_once_with( chat_id="chat-1", - audio_path="/tmp/speech.mp3", + audio_path=media_path, metadata={"thread_id": "topic-1"}, ) adapter.send_document.assert_not_awaited() diff --git a/tests/tools/test_browser_eval_supervisor_path.py b/tests/tools/test_browser_eval_supervisor_path.py index 8528b0994893..5f9b5f52a3d3 100644 --- a/tests/tools/test_browser_eval_supervisor_path.py +++ b/tests/tools/test_browser_eval_supervisor_path.py @@ -361,3 +361,34 @@ def test_no_session_attached_returns_error(self): finally: loop.call_soon_threadsafe(loop.stop) thread.join(timeout=2) + + +class TestBrowserEvalSecurityGuards: + def test_eval_blocks_metadata_fetch_before_supervisor(self, monkeypatch): + import tools.browser_tool as bt + + sup = MagicMock() + _patch_supervisor(monkeypatch, sup) + monkeypatch.setattr(bt, "_is_local_backend", lambda: False) + + out = json.loads(bt._browser_eval("fetch('http://169.254.169.254/latest/meta-data/')")) + + assert out["success"] is False + assert "Blocked" in out["error"] + sup.evaluate_runtime.assert_not_called() + + def test_camofox_eval_blocks_navigation_side_effect(self, monkeypatch): + import tools.browser_tool as bt + import tools.browser_camofox as camofox + + monkeypatch.setattr(bt, "_is_camofox_mode", lambda: True) + monkeypatch.setattr(bt, "_is_local_backend", lambda: True) + post = MagicMock() + monkeypatch.setattr(camofox, "_ensure_tab", lambda task_id: {"tab_id": "t", "user_id": "u"}) + monkeypatch.setattr(camofox, "_post", post) + + out = json.loads(bt._camofox_eval("location.href='http://169.254.169.254/latest/meta-data/'")) + + assert out["success"] is False + assert "Blocked" in out["error"] + post.assert_not_called() diff --git a/tools/browser_tool.py b/tools/browser_tool.py index ec813490d816..8c706ab0736a 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -184,7 +184,7 @@ def _browser_eval_security_error(expression: str) -> Optional[str]: if error: return error - if not _is_local_backend() and _BROWSER_EVAL_NAV_OR_NETWORK_RE.search(expression): + if (not _is_local_backend() or _is_camofox_mode()) and _BROWSER_EVAL_NAV_OR_NETWORK_RE.search(expression): return ( "Blocked: browser JavaScript evaluation cannot perform navigation " "or network requests in cloud browser sessions. Use browser_navigate " @@ -2890,6 +2890,10 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str: """Evaluate a JavaScript expression in the page context and return the result.""" + security_error = _browser_eval_security_error(expression) + if security_error: + return json.dumps({"success": False, "error": security_error}, ensure_ascii=False) + if _is_camofox_mode(): return _camofox_eval(expression, task_id) @@ -2916,6 +2920,10 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str: parsed = json.loads(raw_result) except (json.JSONDecodeError, ValueError): pass # keep as string + final_url_error = _browser_eval_final_url_error(effective_task_id) + if final_url_error: + _blank_browser_after_block(effective_task_id) + return json.dumps({"success": False, "error": final_url_error}, ensure_ascii=False) response = { "success": True, "result": parsed, @@ -2970,6 +2978,11 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str: except (json.JSONDecodeError, ValueError): pass # keep as string + final_url_error = _browser_eval_final_url_error(effective_task_id) + if final_url_error: + _blank_browser_after_block(effective_task_id) + return json.dumps({"success": False, "error": final_url_error}, ensure_ascii=False) + response = { "success": True, "result": parsed, @@ -2980,6 +2993,10 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str: def _camofox_eval(expression: str, task_id: Optional[str] = None) -> str: """Evaluate JS via Camofox's /tabs/{tab_id}/eval endpoint (if available).""" + security_error = _browser_eval_security_error(expression) + if security_error: + return json.dumps({"success": False, "error": security_error}, ensure_ascii=False) + from tools.browser_camofox import _ensure_tab, _post try: tab_info = _ensure_tab(task_id or "default")