lean-ctx 3.9.17

Context Runtime for AI Agents with CCP. 71 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
use super::{SHELL_BUILTINS, ShellError, effective_allowlist, extract_base_from_segment};

/// $(), backticks, <() in arguments: warn by default, **block** when
/// `shell_strict_mode = true` (GH #391 — the strict knob previously only
/// changed the log line and never actually blocked).
pub(crate) fn check_substitution_in_args(command: &str, strict: bool) -> Result<(), ShellError> {
    if !has_expanding_substitution_in_args(command) {
        return Ok(());
    }

    // Extract inner commands from $(...) and check against allowlist + builtins.
    // Only warn/block if the inner command is genuinely non-allowlisted (#1024).
    let inner_cmds = extract_substitution_commands(command);
    if inner_cmds.is_empty() {
        return Ok(());
    }

    let allowlist = effective_allowlist();
    let dangerous: Vec<&str> = inner_cmds
        .iter()
        .filter(|inner| {
            let base = extract_base_from_segment(inner);
            !base.is_empty()
                && !SHELL_BUILTINS.contains(&base.as_str())
                && !allowlist.iter().any(|a| a == &base)
        })
        .map(String::as_str)
        .collect();

    if dangerous.is_empty() {
        return Ok(());
    }

    let names: Vec<String> = dangerous
        .iter()
        .map(|c| extract_base_from_segment(c))
        .collect();

    if strict {
        tracing::warn!(
            "[SECURITY] Command substitution blocked (shell_strict_mode=true): {}",
            names.join(", ")
        );
        return Err(format!(
            "[BLOCKED — DO NOT RETRY] Command substitution with non-allowlisted command: {}. \
             Add to allowlist with `lean-ctx allow <cmd>` or set shell_strict_mode=false.\n\
             Command: {command}",
            names.join(", ")
        )
        .into());
    }
    tracing::warn!(
        "[SECURITY] Command substitution with non-allowlisted command (warn-only): {}",
        names.join(", ")
    );
    Ok(())
}

/// Extracts the base commands from `$(...)` substitutions in argument position.
/// Reuses the same single-quote / backslash-aware scanning as
/// `has_expanding_substitution_in_args` but collects the inner command text.
fn extract_substitution_commands(command: &str) -> Vec<String> {
    let bytes = command.as_bytes();
    let len = bytes.len();
    let mut results = Vec::new();
    let mut i = 0;
    let mut in_single_quote = false;
    let mut seen_space_after_cmd = false;

    while i < len {
        let ch = bytes[i];
        if in_single_quote {
            if ch == b'\'' {
                in_single_quote = false;
            }
            i += 1;
            continue;
        }
        if ch == b'\\' {
            i = (i + 2).min(len);
            continue;
        }
        match ch {
            b'\'' => {
                in_single_quote = true;
                i += 1;
            }
            b' ' | b'\t' if !seen_space_after_cmd => {
                seen_space_after_cmd = true;
                i += 1;
            }
            _ if !seen_space_after_cmd => {
                i += 1;
            }
            _ => {
                if ch == b'$'
                    && i + 1 < len
                    && bytes[i + 1] == b'('
                    && let Some(inner) = extract_paren_content(bytes, i + 1)
                {
                    let trimmed = inner.trim();
                    if !trimmed.is_empty() {
                        results.push(trimmed.to_string());
                    }
                    i += 2 + inner.len() + 1;
                    continue;
                }
                i += 1;
            }
        }
    }
    results
}

/// Extracts content between `(` at `start` and matching `)`, handling nesting.
fn extract_paren_content(bytes: &[u8], start: usize) -> Option<String> {
    if start >= bytes.len() || bytes[start] != b'(' {
        return None;
    }
    let mut depth: u32 = 1;
    let mut i = start + 1;
    while i < bytes.len() && depth > 0 {
        match bytes[i] {
            b'(' => depth += 1,
            b')' => depth -= 1,
            _ => {}
        }
        if depth > 0 {
            i += 1;
        }
    }
    if depth == 0 {
        Some(String::from_utf8_lossy(&bytes[start + 1..i]).to_string())
    } else {
        None
    }
}

/// Check for $(), backticks, <(, >( in arguments wherever the shell would
/// expand them — i.e. unquoted OR inside double quotes (single quotes inhibit
/// expansion). `git commit -m "$(cat f)"` expands; `grep '$(x)' f` does not.
pub(crate) fn has_expanding_substitution_in_args(command: &str) -> bool {
    let bytes = command.as_bytes();
    let len = bytes.len();
    let mut i = 0;
    let mut in_single_quote = false;
    let mut seen_space_after_cmd = false;

    while i < len {
        let ch = bytes[i];
        if in_single_quote {
            if ch == b'\'' {
                in_single_quote = false;
            }
            i += 1;
            continue;
        }
        // Backslash inhibits expansion outside single quotes (GL #1160):
        // `\$(`, `\`` and `\<(` are literal data in bash — both unquoted and
        // inside double quotes.
        if ch == b'\\' {
            i = (i + 2).min(len);
            continue;
        }
        match ch {
            b'\'' => {
                in_single_quote = true;
                i += 1;
            }
            b' ' | b'\t' if !seen_space_after_cmd => {
                seen_space_after_cmd = true;
                i += 1;
            }
            _ if !seen_space_after_cmd => {
                i += 1;
            }
            _ => {
                if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
                    return true;
                }
                if ch == b'`' {
                    return true;
                }
                if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
                    return true;
                }
                i += 1;
            }
        }
    }
    false
}