## 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 -->
162 lines
7.1 KiB
Python
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
|