view release on metacpan or search on metacpan
- Async tool handlers: return Futures for non-blocking I/O
- Async hooks: callbacks can return Futures for async validation
- IO::Async::Loop passed to handlers/hooks for event-driven operations
- Backward compatible: sync handlers/hooks still work unchanged
[Debug Logging]
- Comprehensive operational logging (set CLAUDE_AGENT_DEBUG=1)
- Message lifecycle: receive, queue, deliver, process
- Socket lifecycle: connect, accept, close
- Tool execution: start, complete, results
- Hook execution: matchers, decisions, blocked tools
[Examples]
- examples/12-async-tool.pl: async tool patterns
- examples/13-async-hook.pl: async hook patterns
0.07 2026-01-11
- Added configurable logging system via Log::Any
- New Claude::Agent::Logger module for default adapter setup
- Environment variables: CLAUDE_AGENT_LOG_LEVEL, CLAUDE_AGENT_LOG_OUTPUT
- Backward compatible with CLAUDE_AGENT_DEBUG (1=debug, 2=trace)
lib/Claude/Agent/DryRun.pm view on Meta::CPAN
=back
B<IMPORTANT:> For any production use, ensure C<CLAUDE_AGENT_DRY_RUN_STRICT=1> (the default)
is always enabled. The heuristic fallback mode (C<CLAUDE_AGENT_DRY_RUN_STRICT=0>) is deprecated
and may be removed in a future version.
Do NOT rely on dry-run mode as a security mechanism.
=head1 WRITE TOOLS
The following tools are considered "write" tools and will be blocked
in dry-run mode:
=over 4
=item * Write - File creation/overwrite
=item * Edit - File modification
=item * Bash - Commands that may modify files (detected by heuristics)
lib/Claude/Agent/DryRun.pm view on Meta::CPAN
Custom MCP tools (tools with names starting with C<mcp__>) are allowed by default
in dry-run mode because the system cannot automatically determine whether they
perform write operations.
To block specific MCP tools in dry-run mode, you have the following options:
=over 4
=item * Set the C<CLAUDE_AGENT_MCP_WRITE_TOOLS> environment variable to a
comma-separated list of MCP tool names that should be blocked (e.g.,
C<mcp__myserver__write_file,mcp__myserver__delete_record>)
=item * Implement dry-run logic within the MCP tool itself
=item * Create custom C<PreToolUse> hooks to explicitly block specific MCP tools
=back
=head1 FUNCTIONS
lib/Claude/Agent/DryRun.pm view on Meta::CPAN
$first_cmd //= '';
# Keep full path for safe directory validation
if ($first_cmd =~ m{^/}) {
# Only strip if it's in a known safe directory
return 1 unless $first_cmd =~ m{^/(usr/)?s?bin/};
}
$first_cmd =~ s{^.*/}{};
# Block if command is not in safe list
return 1 unless $safe_commands{$first_cmd};
# Even safe commands are blocked if they use redirects or pipes
return 1 if $command =~ /[>|]/;
return 0; # Safe command without redirects - allow
}
# DEPRECATED: Heuristic fallback mode - will be removed in a future version
# Emit deprecation warning when heuristic mode is used
state $heuristic_warned = 0;
if (!$heuristic_warned++) {
warn "[DEPRECATION WARNING] Dry-run heuristic mode (CLAUDE_AGENT_DRY_RUN_STRICT=0) is "
. "deprecated and provides NO security guarantees. This fallback may be removed in "
. "future versions. Use strict mode (default) for any security-sensitive applications.\n";
}
# Commands that are definitely writes
# Note: This detection is not exhaustive. Complex shell constructs, xargs with
# write commands, or custom scripts may bypass detection. For maximum safety,
# consider using a whitelist approach or reviewing all blocked operations.
#
# IMPORTANT: Dry-run mode provides INFORMATIONAL protection only, NOT security
# guarantees. This regex-based detection can be bypassed using shell obfuscation
# techniques including:
# - Command substitution: $(echo rm) -rf
# - Variable expansion: $cmd where cmd=rm
# - Hex/octal encoding in certain shells
# - Aliases that expand to write commands
# - Less common destructive tools (shred, truncate via other means)
# - Custom scripts that perform write operations
lib/Claude/Agent/DryRun.pm view on Meta::CPAN
return 1 if $command =~ /[>|]\s*\S/; # Redirects or pipes to commands
return 1 if $command =~ /`[^`]*[>|]/; # Command substitution with backticks
return 1 if $command =~ /\$\([^)]*[>|]/; # Command substitution with $()
return 1 if $command =~ /\btee\b/;
return 1 if $command =~ /\bsed\s+-i/; # In-place sed
# If disabling sandbox, likely a write
return 1 if $tool_input->{dangerouslyDisableSandbox};
# In non-strict mode, warn if command is not in the safe list
# This helps users understand what would be blocked in strict mode
if (!$ENV{CLAUDE_AGENT_DRY_RUN_QUIET}) {
my $safe_commands_env = $ENV{CLAUDE_AGENT_DRY_RUN_SAFE_COMMANDS}
// 'ls,cat,head,tail,grep,find,which,pwd,whoami,date,echo,wc,file,stat,type,uname,env,printenv';
my %safe_commands = map { $_ => 1 } split /,/, $safe_commands_env;
my ($first_cmd) = $command =~ /^\s*(\S+)/;
$first_cmd //= '';
$first_cmd =~ s{^.*/}{};
if (!$safe_commands{$first_cmd}) {
warn "[DRY-RUN NOTICE] Command '$first_cmd' is not in safe list but allowed by heuristics. "
. "In strict mode (default), this would be blocked.\n";
}
}
}
# MCP tools - check if marked as write operation via configuration
# Users can specify MCP tools that perform write operations via the
# CLAUDE_AGENT_MCP_WRITE_TOOLS environment variable (comma-separated list)
# or by implementing custom PreToolUse hooks.
if ($tool_name =~ /^mcp__/) {
# Check if tool is in user-configured write-tools list
lib/Claude/Agent/Hook.pm view on Meta::CPAN
hooks => {
PreToolUse => [
Claude::Agent::Hook::Matcher->new(
matcher => 'Bash',
hooks => [sub {
my ($input, $tool_use_id, $context) = @_;
my $command = $input->{tool_input}{command};
if ($command =~ /rm -rf/) {
return {
decision => 'deny',
reason => 'Dangerous command blocked',
};
}
return { decision => 'continue' };
}],
),
],
},
);
=head1 DESCRIPTION
lib/Claude/Agent/Hook/Executor.pm view on Meta::CPAN
my $executor = Claude::Agent::Hook::Executor->new(
hooks => {
PreToolUse => [
Claude::Agent::Hook::Matcher->new(
matcher => 'Bash',
hooks => [sub {
my ($input, $tool_use_id, $context) = @_;
if ($input->{tool_input}{command} =~ /rm -rf/) {
return Claude::Agent::Hook::Result->deny(
reason => 'Dangerous command blocked',
);
}
return Claude::Agent::Hook::Result->proceed();
}],
),
],
},
session_id => $session_id,
);
lib/Claude/Agent/Options.pm view on Meta::CPAN
dry_run => 1,
on_dry_run => sub {
my ($tool_name, $tool_input, $preview) = @_;
print "Would execute $tool_name:\n";
print " $preview\n";
},
);
=head2 on_dry_run
Coderef callback invoked when a tool is blocked in dry-run mode. Receives:
=over 4
=item * tool_name - Name of the tool that would execute
=item * tool_input - HashRef of input parameters
=item * preview - Human-readable preview of what would happen
=back
lib/Claude/Agent/Query.pm view on Meta::CPAN
$log->debug(sprintf("Query: Session ID captured: %s", $self->_session_id // 'none'));
# Update hook executor with session_id
if ($self->_hook_executor) {
$self->_hook_executor->session_id($self->_session_id);
}
}
# Execute Perl hooks for tool use messages
$msg = $self->_execute_hooks_for_message($msg);
# Skip if message was blocked by hooks
if (!defined $msg) {
$log->debug(sprintf("Query: Message type=%s blocked by hooks", $data->{type}));
next;
}
# Re-entrancy guard: if already processing, queue for later
if ($self->_processing_message) {
push @{$self->_messages}, $msg;
next;
}
$self->_processing_message(1);
lib/Claude/Agent/Query.pm view on Meta::CPAN
# Run PreToolUse hooks (must await result for decision)
if ($self->_hook_executor->has_hooks_for('PreToolUse')) {
my $result_future = $self->_hook_executor->run_pre_tool_use(
$tool_name, $tool_input, $tool_use_id
);
# Await the Future - hooks may be async
my $result = $self->_await_hook_result($result_future);
if ($result->{decision} eq 'deny') {
$log->info(sprintf("[HOOK] Blocked tool: %s - %s",
$tool_name, $result->{reason} // 'denied by hook'));
# Send permission denial to CLI
$self->respond_to_permission($tool_use_id, {
behavior => 'deny',
reason => $result->{reason} // 'Denied by Perl hook',
});
# Remove from pending since we denied it
delete $self->_pending_tool_uses->{$tool_use_id};
t/05-hooks.t view on Meta::CPAN
my $results = $future->get;
is(scalar @$results, 2, 'both hooks ran');
is(scalar @call_log, 2, 'both hooks logged');
is($call_log[0]{hook}, 1, 'first hook ran first');
is($call_log[1]{hook}, 2, 'second hook ran second');
# Test early termination on deny
my $deny_matcher = Claude::Agent::Hook::Matcher->new(
hooks => [
sub { return { decision => 'deny', reason => 'Blocked' } },
sub { return { decision => 'continue' } }, # Should not run
],
);
my @deny_log;
$deny_matcher = Claude::Agent::Hook::Matcher->new(
hooks => [
sub {
push @deny_log, 1;
return { decision => 'deny', reason => 'Blocked' };
},
sub {
push @deny_log, 2;
return { decision => 'continue' };
},
],
);
$future = $deny_matcher->run_hooks({}, 'id', {});
$results = $future->get;
t/06-permissions.t view on Meta::CPAN
message => 'Operation not permitted',
);
isa_ok($deny_result, 'Claude::Agent::Permission::Result::Deny');
isa_ok($deny_result, 'Claude::Agent::Permission::Result');
is($deny_result->behavior, 'deny', 'deny result behavior');
is($deny_result->message, 'Operation not permitted', 'deny message');
# Test deny with interrupt
$deny_result = Claude::Agent::Permission->deny(
message => 'Dangerous operation blocked',
interrupt => 1,
);
ok($deny_result->interrupt, 'deny interrupt flag');
# Test deny without interrupt
my $deny_no_interrupt = Claude::Agent::Permission->deny(
message => 'Just denied',
interrupt => 0,
);
ok(!$deny_no_interrupt->interrupt, 'deny without interrupt');
# Test to_hash for deny
my $deny_hash = $deny_result->to_hash;
is($deny_hash->{behavior}, 'deny', 'deny to_hash behavior');
is($deny_hash->{message}, 'Dangerous operation blocked', 'deny to_hash message');
ok(${$deny_hash->{interrupt}}, 'deny to_hash interrupt is JSON true');
$deny_hash = $deny_no_interrupt->to_hash;
ok(!${$deny_hash->{interrupt}}, 'deny to_hash interrupt is JSON false when off');
# Test Permission::Context
my $context = Claude::Agent::Permission::Context->new(
session_id => 'session-abc',
cwd => '/project',
tool_name => 'Write',
t/06-permissions.t view on Meta::CPAN
# Allow everything else
return Claude::Agent::Permission->allow(
updated_input => $input,
);
};
# Test callback with allowed Bash command
my $result = $can_use_tool->('Bash', { command => 'ls -la' }, {});
is($result->behavior, 'allow', 'allowed bash command');
# Test callback with blocked Bash command
$result = $can_use_tool->('Bash', { command => 'rm -rf /' }, {});
is($result->behavior, 'deny', 'blocked dangerous bash command');
is($result->message, 'Recursive delete not allowed', 'correct denial message');
# Test callback with allowed Write
$result = $can_use_tool->('Write', { file_path => '/tmp/test.txt', content => 'hi' }, {});
is($result->behavior, 'allow', 'allowed write to /tmp');
# Test callback with blocked Write
$result = $can_use_tool->('Write', { file_path => '/etc/passwd', content => 'x' }, {});
is($result->behavior, 'deny', 'blocked write outside /tmp');
# Test callback with other tool
$result = $can_use_tool->('Read', { file_path => '/any/path' }, {});
is($result->behavior, 'allow', 'allowed other tools');
done_testing();
t/11-extended-coverage.t view on Meta::CPAN
# Deny with explicit false interrupt
$deny = Claude::Agent::Permission->deny(
message => 'Denied but continue',
interrupt => 0,
);
ok(!$deny->interrupt, 'deny with interrupt => 0');
# Deny with true interrupt
$deny = Claude::Agent::Permission->deny(
message => 'Blocked!',
interrupt => 1,
);
ok($deny->interrupt, 'deny with interrupt => 1');
# Test to_hash JSON boolean encoding
my $hash = $deny->to_hash;
ok(ref($hash->{interrupt}), 'interrupt is a reference (JSON boolean)');
ok(${$hash->{interrupt}}, 'interrupt JSON true value');
$deny = Claude::Agent::Permission->deny(