Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/security/working-with-the-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ executes. When a gate flags a proxied command, proxy refuses with exit 126:
- A gate **block** (supply-chain hard block) cannot be overridden by the
ack variable — restructure the command or use `tirith trust`.

Proxy also refuses known content-reading tools (`grep`, `rg`, `cat`,
`sed`, `head`, `tail`, `diff`, and similar) when a path operand targets a
secret env file such as `.env` or `.env.local`. Template/example files are
allowed: `.env.example`, `.env.sample`, `.env.template`, and matching
`.example` / `.sample` / `.template` suffixes. If you intentionally need
raw secret-file contents through proxy, re-run with
`CONTEXTCRAWLER_ALLOW_SENSITIVE_ENV_READ=1`.

## Enabling the supply-chain gate

The supply-chain gate is off until you write a config file. The first of
Expand Down
140 changes: 140 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,124 @@ fn token_has_path_separator(token: &str) -> bool {
token.contains('/') || token.contains('\\')
}

fn proxy_sensitive_env_read_path(tool: &str, args: &[String]) -> Option<String> {
let tool = bin_basename(tool);
if !matches!(
tool,
"cat"
| "grep"
| "egrep"
| "fgrep"
| "rg"
| "ripgrep"
| "sed"
| "awk"
| "head"
| "tail"
| "nl"
| "less"
| "more"
| "bat"
| "batcat"
| "diff"
) {
return None;
}

let candidates = match tool {
"grep" | "egrep" | "fgrep" | "rg" | "ripgrep" => proxy_search_path_args(args),
"diff" => args
.iter()
.filter(|arg| !arg.starts_with('-'))
.cloned()
.collect(),
_ => proxy_generic_file_args(args),
};

candidates
.into_iter()
.find(|arg| core::sensitive_paths::is_sensitive_env_path(std::path::Path::new(arg)))
}

fn proxy_generic_file_args(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut after_double_dash = false;
let mut skip_next = false;

for arg in args {
if skip_next {
skip_next = false;
continue;
}
if !after_double_dash && arg == "--" {
after_double_dash = true;
continue;
}
if !after_double_dash && arg.starts_with('-') {
if matches!(
arg.as_str(),
"-n" | "--lines" | "-c" | "--bytes" | "-s" | "-f" | "-e"
) {
skip_next = !arg.contains('=');
}
continue;
}
out.push(arg.clone());
}

out
}

fn proxy_search_path_args(args: &[String]) -> Vec<String> {
let mut positionals = Vec::new();
let mut after_double_dash = false;
let mut skip_next = false;
let mut pattern_from_flag = false;

for arg in args {
if skip_next {
skip_next = false;
continue;
}
if !after_double_dash && arg == "--" {
after_double_dash = true;
continue;
}
if !after_double_dash && matches!(arg.as_str(), "-e" | "--regexp") {
pattern_from_flag = true;
skip_next = true;
continue;
}
if !after_double_dash
&& matches!(
arg.as_str(),
"-f" | "--file"
| "-m" | "--max-count"
| "-A" | "--after-context"
| "-B" | "--before-context"
| "-C" | "--context"
| "-g" | "--glob"
| "--iglob"
| "-t" | "--type"
| "-T" | "--type-not"
)
{
skip_next = true;
continue;
}
if !after_double_dash && arg.starts_with('-') {
continue;
}
positionals.push(arg.clone());
}

if pattern_from_flag {
positionals
} else {
positionals.into_iter().skip(1).collect()
}
}

/// `true` when the proxy nudge should be printed to stderr. Three independent
/// suppression knobs (any one silences the nudge): explicit env opt-out,
/// `CI=*` env marker, stderr is not a tty. The override env var can carry
Expand Down Expand Up @@ -4201,6 +4319,28 @@ fn run_cli() -> Result<i32> {
.map(|s| s.to_string_lossy().into_owned())
.collect();

if !core::sensitive_paths::sensitive_env_override_enabled() {
if let Some(path) =
proxy_sensitive_env_read_path(&cmd_name_display, &cmd_args_display)
{
eprintln!(
"contextcrawler: refusing to proxy sensitive env file read `{}` via `{}`. \
Use `{}`=1 only when you intentionally need raw secret-file contents. \
Safe templates such as .env.example, .env.sample, and .env.template are allowed.",
path,
cmd_name_display,
core::sensitive_paths::SENSITIVE_ENV_OVERRIDE
);
timer.track(
&format!("proxy {} (sensitive env refused)", cmd_name_display),
&format!("contextcrawler proxy {}", cmd_name_display),
"",
"",
);
std::process::exit(126);
}
}

if cli.verbose > 0 {
eprintln!(
"Proxy mode: {} {}",
Expand Down
5 changes: 4 additions & 1 deletion src/cmds/git/diff_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Compares two files and shows only the changed lines.

use crate::core::tracking;
use crate::core::{sensitive_paths, tracking};
use anyhow::Result;
use std::fs;
use std::path::Path;
Expand All @@ -9,6 +9,9 @@ use std::path::Path;
pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();

sensitive_paths::ensure_not_sensitive_env_path(file1, "contextcrawler diff")?;
sensitive_paths::ensure_not_sensitive_env_path(file2, "contextcrawler diff")?;

if verbose > 0 {
eprintln!("Comparing: {} vs {}", file1.display(), file2.display());
}
Expand Down
3 changes: 3 additions & 0 deletions src/cmds/system/grep_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use crate::core::config;
use crate::core::runner;
use crate::core::sensitive_paths;
use crate::core::stream::exec_capture;
use crate::core::tracking;
use crate::core::utils::{check_forbidden_rg_args, secure_rg_command};
Expand All @@ -28,6 +29,8 @@ pub fn run(
) -> Result<i32> {
let timer = tracking::TimedExecution::start();

sensitive_paths::ensure_not_sensitive_env_path(std::path::Path::new(path), "contextcrawler grep")?;

if verbose > 0 {
eprintln!("grep: '{}' in {}", pattern, path);
}
Expand Down
3 changes: 3 additions & 0 deletions src/cmds/system/local_llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ use std::fs;
use std::path::Path;

use crate::core::filter::Language;
use crate::core::sensitive_paths;

/// Heuristic-based code summarizer - no external model needed
pub fn run(file: &Path, _model: &str, _force_download: bool, verbose: u8) -> Result<()> {
sensitive_paths::ensure_not_sensitive_env_path(file, "contextcrawler smart")?;

if verbose > 0 {
eprintln!("Analyzing: {}", file.display());
}
Expand Down
4 changes: 3 additions & 1 deletion src/cmds/system/log_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Deduplicates repeated log lines and shows counts instead.

use crate::core::tracking;
use crate::core::{sensitive_paths, tracking};
use anyhow::Result;
use lazy_static::lazy_static;
use regex::Regex;
Expand All @@ -24,6 +24,8 @@ lazy_static! {
pub fn run_file(file: &Path, verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();

sensitive_paths::ensure_not_sensitive_env_path(file, "contextcrawler log")?;

if verbose > 0 {
eprintln!("Analyzing log: {}", file.display());
}
Expand Down
3 changes: 3 additions & 0 deletions src/cmds/system/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::cmds::system::intent as intent_extractor;
use crate::cmds::system::json_cmd;
use crate::core::config;
use crate::core::filter::{self, FilterLevel, Language};
use crate::core::sensitive_paths;
use crate::core::tracking;
use crate::core::utils::format_tokens;
use anyhow::{Context, Result};
Expand All @@ -24,6 +25,8 @@ pub fn run(
) -> Result<()> {
let timer = tracking::TimedExecution::start();

sensitive_paths::ensure_not_sensitive_env_path(file, "contextcrawler read")?;

if verbose > 0 {
eprintln!("Reading: {} (filter: {})", file.display(), level);
}
Expand Down
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod filter;
pub mod runner;
pub mod output_summary;
pub mod secret_redact;
pub mod sensitive_paths;
pub mod stream;
pub mod tee;
pub mod telemetry;
Expand Down
82 changes: 82 additions & 0 deletions src/core/sensitive_paths.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Shared guards for file paths that commonly contain local secrets.

use anyhow::{bail, Result};
use std::ffi::OsStr;
use std::path::Path;

pub const SENSITIVE_ENV_OVERRIDE: &str = "CONTEXTCRAWLER_ALLOW_SENSITIVE_ENV_READ";

pub fn is_sensitive_env_path(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(OsStr::to_str) else {
return false;
};

if !is_dotenv_secret_name(name) {
return false;
}

!is_documented_safe_dotenv_name(name)
}

pub fn ensure_not_sensitive_env_path(path: &Path, surface: &str) -> Result<()> {
if is_sensitive_env_path(path) {
bail!(
"contextcrawler: refusing to read sensitive env file `{}` via {}. \
Use `{}`=1 only when you intentionally need raw secret-file contents. \
Safe templates such as .env.example, .env.sample, and .env.template are allowed.",
path.display(),
surface,
SENSITIVE_ENV_OVERRIDE
);
}
Ok(())
}

pub fn sensitive_env_override_enabled() -> bool {
std::env::var(SENSITIVE_ENV_OVERRIDE).as_deref() == Ok("1")
}

fn is_dotenv_secret_name(name: &str) -> bool {
name == ".env" || name.starts_with(".env.")
}

fn is_documented_safe_dotenv_name(name: &str) -> bool {
name == ".env.example"
|| name == ".env.sample"
|| name == ".env.template"
|| name.ends_with(".example")
|| name.ends_with(".sample")
|| name.ends_with(".template")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn blocks_secret_dotenv_names() {
for path in [".env", ".env.local", ".env.production", "dir/.env.test"] {
assert!(is_sensitive_env_path(Path::new(path)), "{path}");
}
}

#[test]
fn allows_documented_template_names() {
for path in [
".env.example",
".env.sample",
".env.template",
".env.production.example",
"dir/.env.local.template",
] {
assert!(!is_sensitive_env_path(Path::new(path)), "{path}");
}
}

#[test]
fn ignores_non_dotenv_names() {
for path in [".envrc", "env", "README.env", "dotenv"] {
assert!(!is_sensitive_env_path(Path::new(path)), "{path}");
}
}
}
Loading