Claude-Agent
view release on metacpan or search on metacpan
lib/Claude/Agent/MCP/SDKRunner.pm view on Meta::CPAN
# Single state object - reset atomically at start of run()
my $state = Claude::Agent::MCP::SDKRunner::State->new;
# Accessors for backward compatibility with existing code
sub _socket { return $state->{socket}; }
sub _socket_stream { return $state->{socket_stream}; }
sub _request_id { return $state->{request_id}; }
sub _jsonl { return $state->{jsonl}; }
sub _loop { return $state->{loop}; }
sub _response_buffer { return $state->{response_buffer}; }
sub _got_response { return $state->{got_response}; }
sub run {
# Reset all state atomically - no gap between declaration and initialization
$state->reset_state();
binmode(STDIN, ':raw');
binmode(STDOUT, ':raw');
binmode(STDERR, ':encoding(UTF-8)');
# Parse arguments
my ($socket_path, $server_name, $version, $tools_json) = @ARGV;
unless ($socket_path && $server_name && $tools_json) {
die "Usage: SDKRunner <socket_path> <server_name> <version> <tools_json>\n";
}
# Validate socket path - must be absolute and within a known temp directory
# This prevents attackers from pointing to attacker-controlled sockets outside
# the expected secure locations.
die "Invalid socket path: must be absolute\n" unless $socket_path =~ m{^/};
# Validate socket is in a known temporary directory pattern
# Security: Accept common temp directory patterns and user home directories
# where File::Temp would create sockets
my $valid_socket_path = 0;
my @allowed_prefixes = (
'/tmp/',
'/var/tmp/',
'/private/tmp/', # macOS
'/var/folders/', # macOS sandbox temp
'/run/user/', # systemd user runtime
);
# Also allow user home directory temp locations
if ($ENV{HOME} && $ENV{HOME} =~ m{^/}) {
push @allowed_prefixes, "$ENV{HOME}/tmp/";
push @allowed_prefixes, "$ENV{HOME}/.tmp/";
}
# Allow TMPDIR if set (File::Temp respects this)
# *** SECURITY WARNING ***
# TMPDIR is user-controllable and NOT validated for trust.
# KNOWN RISK: An attacker who can set TMPDIR before process startup
# could influence which socket paths are allowed, potentially enabling
# connections to attacker-controlled sockets.
#
# FOR HIGH-SECURITY DEPLOYMENTS: Set CLAUDE_AGENT_IGNORE_TMPDIR=1
#
# Additional mitigations:
# 1. Set CLAUDE_AGENT_IGNORE_TMPDIR=1 to ignore TMPDIR entirely (RECOMMENDED)
# 2. Validate socket ownership with stat() before connecting
# 3. Use only fixed prefixes by not setting TMPDIR
# 4. Run in a restricted environment where TMPDIR cannot be manipulated
# 5. Set TMPDIR to a trusted directory (e.g., /tmp) before process startup
# Only allow TMPDIR when explicitly enabled via CLAUDE_AGENT_ALLOW_TMPDIR=1
# This is opt-in for stricter security - TMPDIR could be attacker-controlled
# SECURITY WARNING: NEVER set CLAUDE_AGENT_ALLOW_TMPDIR=1 in untrusted environments
# or when an attacker could control environment variables before process startup.
# An attacker could set both CLAUDE_AGENT_ALLOW_TMPDIR=1 and a malicious TMPDIR
# to redirect socket connections to attacker-controlled locations.
if ($ENV{TMPDIR} && $ENV{TMPDIR} =~ m{^/} && $ENV{CLAUDE_AGENT_ALLOW_TMPDIR}) {
# SECURITY: Log warning when TMPDIR override is used
warn "[SECURITY WARNING] TMPDIR-based socket path allowed via CLAUDE_AGENT_ALLOW_TMPDIR. "
. "This is insecure if an attacker can control environment variables.\n"
unless $ENV{CLAUDE_AGENT_QUIET_SECURITY_WARNINGS};
push @allowed_prefixes, $ENV{TMPDIR};
push @allowed_prefixes, "$ENV{TMPDIR}/" unless $ENV{TMPDIR} =~ m{/$};
}
require Cwd;
my $resolved_path = Cwd::abs_path($socket_path);
die "Invalid socket path: cannot resolve\n" unless defined $resolved_path;
for my $prefix (@allowed_prefixes) {
my $resolved_prefix = Cwd::abs_path($prefix);
next unless defined $resolved_prefix;
if (index($resolved_path, $resolved_prefix) == 0) {
$valid_socket_path = 1;
last;
}
}
die "Invalid socket path: must be within a temporary directory (/tmp, /var/tmp, TMPDIR, etc.)\n"
unless $valid_socket_path;
# Validate server_name - alphanumeric with hyphens/underscores only
die "Invalid server name: must be alphanumeric with hyphens/underscores\n"
unless $server_name =~ /^[a-zA-Z0-9_-]{1,100}$/;
# Validate version if provided - semver-like format
die "Invalid version format\n"
if defined($version) && length($version) && $version !~ /^[a-zA-Z0-9._-]{1,50}$/;
# Limit tools_json size to prevent memory exhaustion (1MB limit)
die "tools_json too large (max 1MB)\n" if length($tools_json) > 1_000_000;
my ($tools) = $state->{jsonl}->decode($tools_json);
# Validate decoded structure: must be an array of tool definitions
die "Invalid tools_json: expected array\n" unless ref $tools eq 'ARRAY';
for my $tool (@$tools) {
die "Invalid tool definition: expected hash with 'name' key\n"
unless ref $tool eq 'HASH' && defined $tool->{name};
}
# Build tool lookup
my %tool_by_name = map { $_->{name} => $_ } @$tools;
# Validate socket ownership before connecting (defense-in-depth)
# This helps detect if an attacker has replaced the socket with one they control
{
my @stat_info = stat($socket_path);
if (@stat_info) {
my $socket_uid = $stat_info[4];
if ($socket_uid != $<) {
die "Security error: socket '$socket_path' is owned by uid $socket_uid, expected uid $< (current user)\n";
}
}
# If stat fails, the socket may not exist yet - let the connect() call handle it
}
# Connect to parent socket
$state->{socket} = IO::Socket::UNIX->new(
Type => SOCK_STREAM,
Peer => $socket_path,
) or die "Cannot connect to socket $socket_path: $!\n";
$state->{socket}->autoflush(1);
$log->debug(sprintf("SDKRunner: Initializing with socket: %s", $socket_path));
$log->debug("SDKRunner: Connected to parent socket");
# Create IO::Async event loop
$state->{loop} = IO::Async::Loop->new;
# Track running state
my $running = 1;
# Shutdown helper
my $shutdown = sub {
return unless $running;
$running = 0;
$state->{loop}->stop;
};
# Handle signals for graceful shutdown
local $SIG{TERM} = $shutdown;
local $SIG{PIPE} = $shutdown;
# Create async stream for STDIN (from Claude CLI)
my $stdin_stream = IO::Async::Stream->new(
read_handle => \*STDIN,
on_read => sub {
my ($stream, $buffref) = @_;
while ($$buffref =~ s/^([^\n]+)\n//) {
my $line = $1;
next unless length $line;
$log->trace(sprintf("SDKRunner: Received: %s", $line));
my @requests;
my $parse_error;
try {
@requests = $state->{jsonl}->decode($line);
} catch {
$parse_error = $_;
};
if ($parse_error) {
$log->warning(sprintf("SDKRunner: Failed to parse JSON: %s", $parse_error));
next;
}
( run in 1.896 second using v1.01-cache-2.11-cpan-5a3173703d6 )