Claude-Agent

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

        - 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(



( run in 1.375 second using v1.01-cache-2.11-cpan-39bf76dae61 )