arcade-mcp/libs/tests/arcade_mcp_server/test_debug_exposure.py
Francisco Or Something 70515e3356
feat(arcade-core): opt-in debug leak flags for toolkit authors (#826)
## Summary

Adds two strictly opt-in env vars that let toolkit developers see
`developer_message` / `stacktrace` content *in* the agent-facing error
message while debugging. Off by default; activation requires a specific
acknowledgement string, not a boolean — `true`/`1` is explicitly
rejected with a warning log.

- `ARCADE_UNSAFE_DEBUG_LEAK_DEVELOPER_MESSAGE_TO_AGENT`
- `ARCADE_UNSAFE_DEBUG_LEAK_STACKTRACE_TO_AGENT`
- Magic ack: `yes-i-accept-leaking-internals-to-the-agent`

Everything goes through a single funnel — `ToolOutputFactory.fail` /
`fail_retry` in `arcade_core/output.py` — so the behavior covers both
the MCP server path and the Arcade Worker path with no call-site
changes. A loud `logger.warning` fires once per process on activation,
and a big header comment in `output.py` tells future maintainers not to
add more flags of this shape (debug info belongs in `logger.debug`, not
in a field that gets shipped to the model and often to end users).

Bumps `arcade-core` 4.6.2 → 4.7.0. Non-breaking, additive.

## Why

Today the project does a lot of work to keep `developer_message` and
`stacktrace` off the agent's context. That's the right default, but it
makes iterating on a new toolkit painful — you end up adding temporary
logging or rebuilds just to see what blew up. This gives toolkit authors
a safe, ugly, loud-on-activation escape hatch.

## Safety design

- Two separate flags so you only leak what you need.
- Magic string (not a boolean) activates the flag. Boolean-style values
are rejected and log a pointer to `output.py`.
- First activation logs a `WARNING` identifying the flag and the risk.
- Flags documented only in `CLAUDE.md`, not in the public README.
- Top-of-file banner in `output.py` explicitly tells maintainers not to
add more flags of this shape.

## Test plan

- [x] Existing test suite passes (1154 tests —
`libs/tests/{core,tool,arcade_mcp_server}`).
- [x] End-to-end smoke test against the built `arcade_core-4.7.0` wheel,
driven through `ToolExecutor.run` (same path toolkits hit). Covered
cases:
  - flags off → message unchanged
- `ARCADE_UNSAFE_..._DEVELOPER_MESSAGE_TO_AGENT=true` → flag rejected,
warning logged, message unchanged
- `ARCADE_UNSAFE_..._DEVELOPER_MESSAGE_TO_AGENT=<magic>` → `[DEBUG]
developer_message: ...` appended
- both flags with magic, `ToolRuntimeError` path → developer_message
appended (stacktrace absent because `ToolRuntimeError.stacktrace()`
returned `None`, which is existing behavior)
- stacktrace flag with magic, generic `Exception` path → full
`traceback.format_exc()` appended, activation `WARNING` visible

Made with [Cursor](https://cursor.com)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds an opt-in path to include `developer_message` and stacktraces in
agent-facing MCP error messages, which could leak sensitive data if
misconfigured; safeguards (magic ack string + CI/pre-commit guard)
reduce but don’t eliminate risk.
> 
> **Overview**
> Adds `arcade_mcp_server/_debug_exposure.py` with two env-gated debug
flags that, only when set to a specific acknowledgement string, append
`developer_message` and/or `stacktrace` into the agent-visible MCP tool
error `message` (and logs one-shot warnings on rejection/activation).
> 
> Wires this into the MCP error path in `MCPServer._handle_call_tool`,
documents the flags in `CLAUDE.md`, bumps `arcade-mcp-server` to
`1.21.0`, and adds unit + integration tests plus a pre-commit hook and
GitHub Actions workflow (`scripts/check_debug_leak_flags_off.py`) to
ensure the magic ack string can’t be committed outside a small
allowlist.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
30e242c454128ec7cc62e169c2afd116be735cb5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-25 11:40:26 -03:00

162 lines
7.1 KiB
Python

"""Tests for the debug-exposure escape hatch in ``arcade_mcp_server/_debug_exposure.py``."""
import logging
import pytest
from arcade_mcp_server import _debug_exposure as debug_exposure
from arcade_mcp_server._debug_exposure import augment_error_message_for_debug
_LEAK_MAGIC = "yes-i-accept-leaking-internals-to-the-agent"
_ENV_DEV_MSG = "ARCADE_DEBUG_EXPOSE_DEVELOPER_MESSAGE_IN_TOOL_ERROR_RESPONSES"
_ENV_STACKTRACE = "ARCADE_DEBUG_EXPOSE_STACKTRACE_IN_TOOL_ERROR_RESPONSES"
@pytest.fixture(autouse=True)
def _reset_leak_warn_state(monkeypatch):
"""Clear the per-process one-shot warning state so each test starts clean.
Both flags emit loud warnings (rejection and activation) one-shot per flag.
Without a reset, later tests would silently lose coverage of those branches
because the module-level tracking sets are already populated from earlier
tests.
"""
monkeypatch.delenv(_ENV_DEV_MSG, raising=False)
monkeypatch.delenv(_ENV_STACKTRACE, raising=False)
debug_exposure._warned_rejected.clear()
debug_exposure._warned_activated.clear()
yield
debug_exposure._warned_rejected.clear()
debug_exposure._warned_activated.clear()
def test_no_leak_by_default():
"""With both flags unset, message must not be augmented."""
out = augment_error_message_for_debug(
"public error",
developer_message="secret internals",
stacktrace="Traceback...\n line",
)
assert out == "public error"
@pytest.mark.parametrize("bad_value", ["true", "1", "yes", "on", "TRUE", "True"])
def test_rejects_boolean_activation(monkeypatch, caplog, bad_value):
"""Any truthy-looking value that isn't the magic string must be rejected."""
monkeypatch.setenv(_ENV_DEV_MSG, bad_value)
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert out == "public error"
assert any(
"set to a truthy value but not to the required" in rec.message for rec in caplog.records
)
def test_rejects_random_non_magic_value(monkeypatch, caplog):
"""A non-boolean-looking value that isn't the magic string is silently off."""
monkeypatch.setenv(_ENV_DEV_MSG, "debug-please")
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert out == "public error"
assert not any(
"set to a truthy value but not to the required" in rec.message for rec in caplog.records
)
def test_developer_message_flag_enabled(monkeypatch, caplog):
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace="trace"
)
assert "public error" in out
assert "[DEBUG] developer_message: secret internals" in out
# Stacktrace flag is off → stacktrace must NOT be in the augmented text.
assert "trace" not in out.replace("public error", "")
assert any("is ENABLED" in rec.message for rec in caplog.records)
def test_stacktrace_flag_enabled(monkeypatch):
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug(
"public error",
developer_message="secret internals",
stacktrace="Traceback (most recent call last):\n File ...",
)
assert "public error" in out
assert "[DEBUG] stacktrace:" in out
assert "File ..." in out
# Developer-message flag off → dev message must NOT leak.
assert "secret internals" not in out
def test_both_flags_enabled(monkeypatch):
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug(
"public error", developer_message="dev info", stacktrace="trace info"
)
assert "[DEBUG] developer_message: dev info" in out
assert "[DEBUG] stacktrace:\ntrace info" in out
def test_flag_enabled_but_no_content_to_leak(monkeypatch):
"""Flag on but developer_message/stacktrace are None → message unchanged."""
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug("public error", None, None)
assert out == "public error"
def test_activation_warning_emitted_once_per_process(monkeypatch, caplog):
"""Second call with the flag on must NOT emit another activation warning."""
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
augment_error_message_for_debug("a", developer_message="dev", stacktrace=None)
first_count = sum("is ENABLED" in r.message for r in caplog.records)
augment_error_message_for_debug("b", developer_message="dev", stacktrace=None)
second_count = sum("is ENABLED" in r.message for r in caplog.records)
assert first_count == 1
assert second_count == 1 # one-shot per process
def test_rejection_does_not_suppress_later_activation_warning(monkeypatch, caplog):
"""Regression: once a truthy-but-non-magic value has been rejected for a
flag, correcting the value to the magic string within the same process
must still emit the critical "ENABLED ... DO NOT USE IN PRODUCTION"
warning. Previously both paths shared one state set, so the activation
warning was silently swallowed in this scenario.
"""
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
monkeypatch.setenv(_ENV_DEV_MSG, "true")
out_rejected = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert "[DEBUG]" not in out_rejected
rejection_count = sum(
"set to a truthy value but not to the required" in r.message for r in caplog.records
)
assert rejection_count == 1
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
out_activated = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert "[DEBUG] developer_message: secret internals" in out_activated
activation_count = sum("is ENABLED" in r.message for r in caplog.records)
assert activation_count == 1, (
"activation warning must fire even after the rejection warning "
"has already been emitted for the same flag in this process"
)
def test_magic_value_ignores_surrounding_whitespace(monkeypatch):
"""Leading/trailing whitespace around the magic string still activates the flag."""
monkeypatch.setenv(_ENV_DEV_MSG, f" {_LEAK_MAGIC} ")
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert "[DEBUG] developer_message: secret internals" in out