arcade-mcp/scripts/check_debug_leak_flags_off.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

126 lines
4.3 KiB
Python
Executable file

#!/usr/bin/env python3
# ruff: noqa: S603, S607
#
# This script shells out to `git` via PATH on purpose: it runs inside
# pre-commit and GitHub Actions, both of which guarantee git on PATH, and
# hard-coding an absolute path would break portability. The subprocess
# invocations here pass only constant argv lists, so S603/S607 don't apply.
"""
Guard: the debug-exposure flags in ``arcade_mcp_server/_debug_exposure.py``
must never ship in the "on" state through committed files.
The two env vars
ARCADE_DEBUG_EXPOSE_DEVELOPER_MESSAGE_IN_TOOL_ERROR_RESPONSES
ARCADE_DEBUG_EXPOSE_STACKTRACE_IN_TOOL_ERROR_RESPONSES
only activate when set to one specific acknowledgement string. Therefore we
only need to guarantee that string never appears in the tree outside a tiny
allowlist of files (the source that defines it, the tests that exercise it,
the developer doc, and this guard itself).
This script is run both as a pre-commit hook and as a dedicated CI workflow.
Exit codes:
0 OK — flags cannot be activated by anything in the tree.
1 FAIL — the magic string was found in a non-allowlisted file.
2 Infrastructure error (e.g. ``git ls-files`` unavailable).
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
# The activation ack string. Kept as the sole constant so updating it in one
# place (arcade_mcp_server/_debug_exposure.py) also updates the guard.
MAGIC = "yes-i-accept-leaking-internals-to-the-agent"
# Files that are *allowed* to mention the magic string. Everything else is a
# hard fail. Paths are relative to the repository root and use forward slashes.
ALLOWLIST: frozenset[str] = frozenset({
# The source of truth for the flags.
"libs/arcade-mcp-server/arcade_mcp_server/_debug_exposure.py",
# Unit tests for the pure augmentation function.
"libs/tests/arcade_mcp_server/test_debug_exposure.py",
# Integration tests for the MCP-boundary wire-up.
"libs/tests/arcade_mcp_server/test_debug_exposure_integration.py",
# Developer documentation for the flags.
"CLAUDE.md",
# This guard itself.
"scripts/check_debug_leak_flags_off.py",
})
def _repo_root() -> Path:
try:
out = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
text=True,
stderr=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, FileNotFoundError):
print("check_debug_leak_flags_off: not a git checkout", file=sys.stderr)
raise SystemExit(2) from None
return Path(out.strip())
def _tracked_files(root: Path) -> list[str]:
try:
out = subprocess.check_output(
["git", "-C", str(root), "ls-files"],
text=True,
stderr=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, FileNotFoundError):
print("check_debug_leak_flags_off: git ls-files failed", file=sys.stderr)
raise SystemExit(2) from None
return [line for line in out.splitlines() if line]
def main() -> int:
root = _repo_root()
failures: list[str] = []
for rel in _tracked_files(root):
if rel in ALLOWLIST:
continue
path = root / rel
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
if MAGIC in text:
failures.append(rel)
if failures:
print("Debug-leak flag guard: FAIL", file=sys.stderr)
print("", file=sys.stderr)
print(
"The activation acknowledgement string for the unsafe debug-leak "
"flags was found in files that must never contain it:",
file=sys.stderr,
)
for f in failures:
print(f" - {f}", file=sys.stderr)
print("", file=sys.stderr)
print(
"These env vars must stay off by default everywhere the repo ships. "
"If you need to iterate locally, export the magic value in your "
"shell only — never commit it.",
file=sys.stderr,
)
print(
"See libs/arcade-mcp-server/arcade_mcp_server/_debug_exposure.py "
"for the full rationale.",
file=sys.stderr,
)
return 1
print("Debug-leak flag guard: OK")
return 0
if __name__ == "__main__":
sys.exit(main())