Claude-Agent

 view release on metacpan or  search on metacpan

lib/Claude/Agent/DryRun.pm  view on Meta::CPAN

    for my $change (@$changes) {
        print "Would have used: $change->{tool}\n";
    }

=head1 DESCRIPTION

This module provides dry-run functionality for the Claude Agent SDK.
In dry-run mode, file-modifying tools are intercepted and their effects
are previewed without executing them.

B<============================================================================>

B<SECURITY WARNING: DRY-RUN MODE IS FOR PREVIEW ONLY - NOT A SECURITY BOUNDARY>

B<============================================================================>

Dry-run mode provides B<preview functionality only>, B<NOT security guarantees>.
Do NOT rely on dry-run mode to prevent malicious or unintended command execution.

By default, C<CLAUDE_AGENT_DRY_RUN_STRICT=1> is enabled, which uses a whitelist
approach for Bash commands. If you disable strict mode (C<CLAUDE_AGENT_DRY_RUN_STRICT=0>),
the Bash command detection falls back to regex-based heuristics that can be
B<easily bypassed> through shell obfuscation techniques including:

=over 4

=item * Command substitution: C<$(echo rm) -rf>

=item * Variable expansion: C<$cmd> where C<cmd=rm>

=item * Hex/octal encoding in certain shells

=item * Aliases that expand to destructive commands

=item * Custom scripts or less common destructive tools

=back

B<For security-critical applications, use:>

=over 4

=item * Explicit tool whitelisting via C<allowed_tools>

=item * Containerization (Docker, etc.)

=item * Sandboxed/isolated environments

=item * Custom C<PreToolUse> hooks with strict validation

=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)

=item * NotebookEdit - Jupyter notebook modifications

=back

Read-only tools execute normally:

=over 4

=item * Read - File reading

=item * Glob - File pattern matching

=item * Grep - Content searching

=item * WebFetch - Web content fetching

=item * WebSearch - Web searching

=back

=head1 MCP TOOLS AND DRY-RUN MODE

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

=head2 create_dry_run_hooks

    my $hooks = create_dry_run_hooks($on_dry_run_callback);

Creates hook matchers for dry-run mode. Returns a hashref suitable
for the C<hooks> option.

=cut

sub create_dry_run_hooks {
    my ($on_dry_run) = @_;

    # Emit prominent warning about dry-run limitations unless suppressed
    unless ($ENV{CLAUDE_AGENT_DRY_RUN_NO_WARN}) {
        warn "=" x 72 . "\n";
        warn "[DRY-RUN MODE WARNING]\n";
        warn "Dry-run mode provides PREVIEW FUNCTIONALITY ONLY, not security.\n";
        warn "Bash command detection uses regex heuristics that can be BYPASSED via:\n";
        warn "  - Command substitution: \$(echo rm) -rf\n";
        warn "  - Variable expansion: \$cmd where cmd=rm\n";
        warn "  - Aliases, scripts, or encoded commands\n";
        warn "For security-critical use, use tool whitelisting or sandboxing.\n";
        warn "Set CLAUDE_AGENT_DRY_RUN_NO_WARN=1 to suppress this warning.\n";
        warn "=" x 72 . "\n";
    }

    return {
        PreToolUse => [
            Claude::Agent::Hook::Matcher->new(
                hooks => [sub {
                    my ($input, $tool_use_id, $context) = @_;
                    my $tool_name = $input->{tool_name};
                    my $tool_input = $input->{tool_input};

                    # Check if this is a write tool
                    if (is_write_tool($tool_name, $tool_input)) {
                        my $preview = format_preview($tool_name, $tool_input);

                        # Call the callback if provided
                        if ($on_dry_run && ref($on_dry_run) eq 'CODE') {
                            $on_dry_run->($tool_name, $tool_input, $preview);
                        }
                        else {
                            # Default output
                            print "[DRY-RUN] $preview\n";
                        }

                        # Block the tool with informative message
                        return Claude::Agent::Hook::Result->deny(
                            reason => "[DRY-RUN] $preview",

lib/Claude/Agent/DryRun.pm  view on Meta::CPAN

    };
}

=head2 is_write_tool

    my $is_write = is_write_tool($tool_name, $tool_input);

Returns true if the tool is considered a write operation.

=cut

sub is_write_tool {
    my ($tool_name, $tool_input) = @_;

    # Definite write tools
    return 1 if $tool_name eq 'Write';
    return 1 if $tool_name eq 'Edit';
    return 1 if $tool_name eq 'NotebookEdit';

    # Bash commands need heuristic detection
    # Note: This check is for preview/convenience only - NOT a security boundary.
    # For security-critical use cases, use explicit tool whitelisting or containerization.
    if ($tool_name eq 'Bash') {
        my $command = $tool_input->{command} // '';

        # SECURITY: Strict mode is the DEFAULT and STRONGLY RECOMMENDED
        # Set CLAUDE_AGENT_DRY_RUN_STRICT=0 to enable heuristic mode (DEPRECATED - NOT RECOMMENDED)
        # WARNING: Heuristic detection provides NO security guarantees and is EASILY BYPASSED
        # via shell obfuscation (command substitution, variable expansion, encoding, etc.)
        # The heuristic fallback exists only for legacy compatibility and may be removed in future versions.
        # For any security-sensitive use case, keep strict mode enabled (the default).
        if (!defined $ENV{CLAUDE_AGENT_DRY_RUN_STRICT} || $ENV{CLAUDE_AGENT_DRY_RUN_STRICT}) {
            # In strict mode, only allow explicitly whitelisted read-only commands
            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';
            # Validate each command name to prevent injection via malicious env values
            # Only allow alphanumeric characters, hyphens, and underscores
            my @raw_commands = split /,/, $safe_commands_env;
            my @valid_commands = grep { /^[a-zA-Z0-9_-]+$/ } @raw_commands;
            # Log filtered entries to help users debug configuration issues
            if (@valid_commands < @raw_commands) {
                my %valid_set = map { $_ => 1 } @valid_commands;
                my @filtered = grep { !$valid_set{$_} } @raw_commands;
                warn sprintf("[DRY-RUN CONFIG] Filtered %d invalid entries from CLAUDE_AGENT_DRY_RUN_SAFE_COMMANDS: %s\n",
                    scalar(@filtered), join(', ', map { "'$_'" } @filtered));
            }
            my %safe_commands = map { $_ => 1 } @valid_commands;

            # Extract first command word (before any args, pipes, or redirects)
            my ($first_cmd) = $command =~ /^\s*(\S+)/;
            $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
        #
        # For security-critical applications, consider:
        #   1. Set CLAUDE_AGENT_DRY_RUN_STRICT=1 to block all non-whitelisted commands
        #   2. Running in a sandboxed environment (containers, VMs)
        #   3. Using custom PreToolUse hooks with stricter validation
        #
        # WARNING: Always print security notice to STDERR for Bash commands
        # This ensures users are aware of limitations even if callbacks suppress output
        if (!$ENV{CLAUDE_AGENT_DRY_RUN_QUIET}) {
            state $dry_run_warned = 0;
            warn "[DRY-RUN WARNING] Bash command detection is bypassable. "
                . "Set CLAUDE_AGENT_DRY_RUN_STRICT=1 for stricter protection.\n"
                unless $dry_run_warned++;
        }
        # More precise command detection: check if dangerous command is at start or after pipe/semicolon/&&
        # This avoids false positives like 'grep rm file.txt' or 'echo rm > log.txt'
        my @dangerous_cmds = qw(rm rmdir mv cp mkdir touch chmod chown dd truncate install ln patch rsync shred);
        for my $cmd (@dangerous_cmds) {
            return 1 if $command =~ /^\s*$cmd\b/ || $command =~ /[;|&]\s*$cmd\b/;
        }
        # Handle wget and curl with output flags separately (more complex patterns)
        return 1 if $command =~ /^\s*wget\b/ || $command =~ /[;|&]\s*wget\b/;
        return 1 if $command =~ /^\s*curl\s+.*-[oO]/ || $command =~ /[;|&]\s*curl\s+.*-[oO]/;
        return 1 if $command =~ /<<[<]?/;  # Heredoc redirects
        return 1 if $command =~ /\b(perl|python|ruby|sh|bash)\s+(-[ec]|-.*[ec])/i;  # Inline scripts that could write
        return 1 if $command =~ /\beval\b/;  # eval command
        return 1 if $command =~ /\b(source|\.)\s+/;  # source command
        return 1 if $command =~ /\bxargs\b.*\b(rm|mv|cp)\b/;  # xargs with write commands
        return 1 if $command =~ /\b(git\s+(add|commit|push|reset|checkout|merge|rebase))\b/;
        return 1 if $command =~ /\b(npm\s+(install|uninstall|update|publish))\b/;
        return 1 if $command =~ /\b(pip\s+(install|uninstall))\b/;
        return 1 if $command =~ /\b(cargo\s+(build|install|publish))\b/;
        return 1 if $command =~ /\b(make|cmake)\b/;
        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
        my $write_tools_env = $ENV{CLAUDE_AGENT_MCP_WRITE_TOOLS} // '';
        my %write_mcp_tools = map { $_ => 1 } split /,/, $write_tools_env;
        return 1 if $write_mcp_tools{$tool_name};
        return 0;
    }

    # Default: allow (read-only tools like Read, Glob, Grep, etc.)
    return 0;
}

=head2 format_preview

    my $preview = format_preview($tool_name, $tool_input);

Formats a human-readable preview of what the tool would do.

=cut

sub format_preview {
    my ($tool_name, $tool_input) = @_;

    if ($tool_name eq 'Write') {
        my $path = $tool_input->{file_path} // 'unknown';
        my $content = $tool_input->{content} // '';
        my $lines = () = $content =~ /\n/g;
        $lines++;
        my $bytes = length($content);
        return "Would write $bytes bytes ($lines lines) to: $path";
    }

    if ($tool_name eq 'Edit') {
        my $path = $tool_input->{file_path} // 'unknown';
        my $old = $tool_input->{old_string} // '';
        my $new = $tool_input->{new_string} // '';
        my $old_preview = length($old) > 50 ? substr($old, 0, 47) . '...' : $old;
        my $new_preview = length($new) > 50 ? substr($new, 0, 47) . '...' : $new;
        $old_preview =~ s/\n/\\n/g;
        $new_preview =~ s/\n/\\n/g;
        my $replace_all = $tool_input->{replace_all} ? ' (all occurrences)' : '';
        return "Would edit $path$replace_all: '$old_preview' -> '$new_preview'";
    }

    if ($tool_name eq 'Bash') {
        my $cmd = $tool_input->{command} // 'unknown';
        my $desc = $tool_input->{description} // '';
        my $cmd_preview = length($cmd) > 80 ? substr($cmd, 0, 77) . '...' : $cmd;
        return $desc ? "Would run: $cmd_preview ($desc)" : "Would run: $cmd_preview";
    }

    if ($tool_name eq 'NotebookEdit') {



( run in 1.940 second using v1.01-cache-2.11-cpan-75ffa21a3d4 )