Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f92666c
fix(code): capture stdio MCP server stderr into the logger
mdrxy Aug 18, 2026
345fdb5
Merge branch 'main' into mdrxy/code/mcp-stdio-stderr-logging
mdrxy Aug 19, 2026
181b5d0
fix(code): bound MCP stderr drain join and force-close leaked pipe
mdrxy Aug 19, 2026
be30003
fix(code): restrict debug log to the current user on Windows
mdrxy Aug 19, 2026
cb883ac
fix(code): bound forced MCP stderr drain teardown
mdrxy Aug 19, 2026
5b315b3
fix(code): grant the debug log DACL to the correct trustee
mdrxy Aug 19, 2026
555f587
test(code): run deepagents-code tests on Windows
mdrxy Aug 19, 2026
0c5893b
fix(code): disable file logging when the debug log cannot be secured
mdrxy Aug 19, 2026
c82a667
perf(code): import ctypes only on Windows
mdrxy Aug 19, 2026
373a435
fix(code): serialize the MCP stderr pipe close across threads
mdrxy Aug 19, 2026
919158f
fix(code): report MCP stderr drain failures when capture is off
mdrxy Aug 19, 2026
8a5bbd8
fix(code): decode captured MCP stderr with the replace handler
mdrxy Aug 19, 2026
a047d9f
refactor(code): drop the unreachable MCP stderr write path
mdrxy Aug 19, 2026
a2d93e5
refactor(code): drop the redundant stdio env resolution
mdrxy Aug 19, 2026
df09825
docs(code): correct the DACL, teardown and sanitization claims
mdrxy Aug 19, 2026
bf74f90
fix(code): close both pipe ends when the stderr sink fails to start
mdrxy Aug 19, 2026
80319e6
Merge branch 'main' into mdrxy/code/mcp-stdio-stderr-logging
mdrxy Aug 19, 2026
7ab3b7c
test(code): make timestamp footer hydration test deterministic
mdrxy Aug 19, 2026
85078a5
test(code): drop thread-death assertion from leaked-pipe stderr test
mdrxy Aug 19, 2026
f0edf4f
Merge branch 'main' into mdrxy/code/mcp-stdio-stderr-logging
mdrxy Aug 19, 2026
c63d535
ci(infra): drop windows-latest from deepagents-code test matrix
mdrxy Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions libs/code/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ tail -f /tmp/deepagents_debug.log

To send it elsewhere, also `export DEEPAGENTS_CODE_DEBUG_FILE=<path>`. The handler appends across runs, so a single file accumulates every session.

The file is created or tightened to user-only access. A symlink at the path is refused. If the file cannot be secured, no file handler is attached and a warning goes to stderr. Use the in-app Debug Console in that case.

Stdio MCP server stderr is captured here at `DEBUG`. This keeps server-side failures visible when the TUI cannot show process stderr. Each record is one line, capped at 4096 characters. Characters in the Unicode `C` categories are removed, which includes control and format characters. The ESC byte of an ANSI sequence is removed but the rest stays as literal text, so the log is not free of escape-sequence residue. The text comes from the server. It can contain credentials or other sensitive values. Enable `DEBUG` logging only if you accept that risk, and do not share the log file.

### In-app Debug Console (`Ctrl+\`)

Press `Ctrl+\` (or run the hidden `/debug` command) inside a session to toggle a read-only Debug Console overlay. It shows a point-in-time session/runtime snapshot (version, model, thread, cwd, auto-approve, sandbox, MCP servers, token usage, debug-log path) plus a live tail of recent `deepagents_code.*` log records.
Expand Down
223 changes: 223 additions & 0 deletions libs/code/deepagents_code/_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
import sys
from pathlib import Path

# Windows-only ACL plumbing; see `_apply_windows_owner_only_dacl`. Imported
# under the guard because `_debug` is on the startup path for every command and
# `ctypes` costs a few milliseconds it can never repay on POSIX.
if os.name == "nt":
import ctypes
from ctypes import wintypes

from deepagents_code._env_vars import (
DEBUG,
DEBUG_FILE,
Expand All @@ -39,6 +46,200 @@
"""


def _prepare_debug_file(path: Path) -> None:
"""Create or tighten a debug file before attaching the logging handler.

On POSIX the file is created or tightened to mode `0o600`. On Windows,
where `os.open` mode bits and `chmod` do not tighten the DACL, the DACL is
replaced with one granting read and write access to the current user only.

`O_NOFOLLOW` refuses a symlink at `path`. The default location is a
world-writable temp directory, so without it a planted symlink could
redirect captured MCP server stderr into a file of the attacker's choosing.

Raises:
OSError: If the file cannot be created, opened, or tightened. The
caller must treat this as fatal to file logging.
""" # noqa: DOC502 - raised by os.open/fchmod, not by an explicit raise
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(path, flags, 0o600)
try:
if os.name == "nt":
_set_windows_owner_only_dacl(path)
return
fchmod = getattr(os, "fchmod", None)
if fchmod is None:
path.chmod(0o600)
else:
fchmod(fd, 0o600)
Comment thread
open-swe[bot] marked this conversation as resolved.
finally:
os.close(fd)


def _set_windows_owner_only_dacl(path: Path) -> None:
"""Restrict `path` to the current user on Windows.

This is a no-op on POSIX, where `_prepare_debug_file` uses mode `0o600`
instead. The Windows implementation (defined only when `os.name == "nt"`)
replaces the file's DACL with one granting read and write access to the
current user and no one else.

Args:
path: Debug log file to lock down.

Raises:
OSError: If the DACL cannot be built or applied. `_prepare_debug_file`
propagates it; `configure_debug_logging` catches it and disables
file logging.
""" # noqa: DOC502 - raised by the callee, not by an explicit raise
if os.name != "nt":
return
_apply_windows_owner_only_dacl(path)


if os.name == "nt":
# --- Windows user-only DACL ---------------------------------------------
# Structures and helpers mirroring the advapi32 API used to build and apply
# a DACL granting the current user read and write access, and no one else
# any access. `DELETE` and `WRITE_DAC` are deliberately not granted; the
# file owner retains them implicitly.

_SE_FILE_OBJECT = 1
_DACL_SECURITY_INFORMATION = 0x00000004
_PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
_TOKEN_QUERY = 0x0008
_TOKEN_USER_INFORMATION_CLASS = 1
_FILE_GENERIC_READ = 0x120089
_FILE_GENERIC_WRITE = 0x120116
# `TRUSTEE_FORM` / `TRUSTEE_TYPE` / `ACCESS_MODE` from `accctrl.h`. Named
# rather than inlined because all three enums start at 0 with unrelated
# meanings, so a transposed literal still compiles and is rejected only at
# runtime by `SetEntriesInAclW`.
_NO_MULTIPLE_TRUSTEE = 0
_TRUSTEE_IS_SID = 0
_TRUSTEE_IS_USER = 1
_SET_ACCESS = 2
_NO_INHERITANCE = 0

class _TRUSTEE_W(ctypes.Structure): # noqa: N801 # mirrors Win32 TRUSTEE_W
"""`TRUSTEE_W` identifying the current-user SID to `SetEntriesInAclW`."""

_fields_ = [
("pMultipleTrustee", ctypes.c_void_p),
("MultipleTrusteeOperation", ctypes.c_int),
("TrusteeForm", ctypes.c_int),
("TrusteeType", ctypes.c_int),
("ptstrName", ctypes.c_void_p),
]

class _EXPLICIT_ACCESS_W(ctypes.Structure): # noqa: N801 # mirrors Win32 type
"""`EXPLICIT_ACCESS_W` describing one access-control entry."""

_fields_ = [
("grfAccessPermissions", wintypes.DWORD),
("grfAccessMode", ctypes.c_int),
("grfInheritance", wintypes.DWORD),
("Trustee", _TRUSTEE_W),
]

def _get_current_user_sid() -> ctypes.c_void_p:
"""Return a pointer to the current user's SID.

The `TOKEN_USER` buffer the SID points into is attached to the returned
pointer as `_buffer`, so it stays alive for the DACL construction.
`ctypes` already retains it through `.contents`; the attribute makes
that guarantee explicit rather than incidental.

Returns:
A pointer to the current user's SID.

Raises:
OSError: If the process token or user SID cannot be read. Raised
via `ctypes.WinError`, which is a factory returning `OSError`.
""" # noqa: DOC501, DOC502 - `ctypes.WinError` returns an `OSError`
advapi32 = ctypes.windll.advapi32
token = wintypes.HANDLE()
if not advapi32.OpenProcessToken(
ctypes.windll.kernel32.GetCurrentProcess(),
_TOKEN_QUERY,
ctypes.byref(token),
):
raise ctypes.WinError() # surface the raw OS error
try:
needed = wintypes.DWORD(0)
advapi32.GetTokenInformation(
token, _TOKEN_USER_INFORMATION_CLASS, None, 0, ctypes.byref(needed)
)
if not needed.value:
raise ctypes.WinError()
buffer = (ctypes.c_byte * needed.value)()
if not advapi32.GetTokenInformation(
token,
_TOKEN_USER_INFORMATION_CLASS,
buffer,
needed,
ctypes.byref(needed),
):
raise ctypes.WinError()
# TOKEN_USER begins with a single pointer to the user's SID.
sid = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p)).contents
# Keep the backing buffer alive by attaching it to the pointer object.
sid._buffer = buffer # type: ignore[attr-defined]
return sid
finally:
ctypes.windll.kernel32.CloseHandle(token)

def _apply_windows_owner_only_dacl(path: Path) -> None:
"""Replace `path`'s DACL with a single read/write entry for this user.

The DACL is marked protected, so entries inherited from the parent
directory are dropped rather than merged.

Args:
path: Debug log file to lock down.

Raises:
OSError: If the DACL cannot be built or applied. Raised via
`ctypes.WinError`, which is a factory returning `OSError`.
""" # noqa: DOC501, DOC502 - `ctypes.WinError` returns an `OSError`
advapi32 = ctypes.windll.advapi32
sid = _get_current_user_sid()

trustee = _TRUSTEE_W(
pMultipleTrustee=None,
MultipleTrusteeOperation=_NO_MULTIPLE_TRUSTEE,
TrusteeForm=_TRUSTEE_IS_SID,
TrusteeType=_TRUSTEE_IS_USER,
ptstrName=ctypes.cast(sid, ctypes.c_void_p).value,
)
explicit = _EXPLICIT_ACCESS_W(
grfAccessPermissions=_FILE_GENERIC_READ | _FILE_GENERIC_WRITE,
grfAccessMode=_SET_ACCESS,
grfInheritance=_NO_INHERITANCE,
Trustee=trustee,
)
new_acl = ctypes.c_void_p()
result = advapi32.SetEntriesInAclW(
1, ctypes.byref(explicit), None, ctypes.byref(new_acl)
)
if result != 0: # ERROR_SUCCESS
raise ctypes.WinError(result)
try:
apply_result = advapi32.SetNamedSecurityInfoW(
str(path),
_SE_FILE_OBJECT,
_DACL_SECURITY_INFORMATION | _PROTECTED_DACL_SECURITY_INFORMATION,
None,
None,
new_acl,
None,
)
if apply_result != 0: # ERROR_SUCCESS
raise ctypes.WinError(apply_result)
finally:
ctypes.windll.kernel32.LocalFree(new_acl)


def resolve_log_level(*, debug_enabled: bool | None = None) -> int:
"""Resolve the configured runtime logging level.

Expand Down Expand Up @@ -88,6 +289,10 @@ def configure_debug_logging(target: logging.Logger) -> None:
is reused and its level re-applied. If the resolved path changes, the stale
handler is closed and replaced.

The file is created or tightened to user-only access first. If that fails,
no file handler is attached: captured MCP server stderr can carry
credentials, so no file log is safer than one that could not be secured.

Args:
target: Logger to configure.
"""
Expand All @@ -113,6 +318,24 @@ def configure_debug_logging(target: logging.Logger) -> None:
target.removeHandler(existing)
existing.close()

try:
_prepare_debug_file(debug_path)
except OSError as exc:
# Fail closed. `_prepare_debug_file` opens with `O_NOFOLLOW`, so this
# also fires for a symlink planted at `debug_path` — and the
# `FileHandler` below would happily follow it, turning a blocked
# redirect into a successful one. Captured MCP server stderr can carry
# credentials, so skip file logging entirely; the in-memory buffer
# still backs the Debug Console.
message = (
f"could not restrict debug log file {debug_path} to the current "
f"user: {exc}. File logging is disabled because captured MCP "
f"server stderr may contain credentials. Set "
f"{DEBUG_FILE} to a path you own to enable it."
)
print(f"Warning: {message}", file=sys.stderr) # noqa: T201
logger.warning("%s", message)
return
try:
handler = logging.FileHandler(str(debug_path), mode="a")
except OSError as exc:
Expand Down
Loading