Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ jobs:
run: cargo check
- name: Test
run: cargo test
- name: Clean tree
run: git diff --exit-code && git diff --cached --exit-code
- name: Clippy
run: cargo clippy -- -D warnings
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,6 @@ ctxt --help
ctxt doctor
ctxt providers list
ctxt version
```

Planned commands:

```bash
ctxt context inspect
ctxt context pack --task "..."
ctxt ask --dry-run "..."
Expand Down
49 changes: 49 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,42 @@ fn normalize_path(path: &std::path::Path) -> String {
s.replace('\\', "/")
}

fn is_sensitive_context_path(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let lower = normalized.to_ascii_lowercase();
let file_name = lower.rsplit('/').next().unwrap_or(lower.as_str());

file_name == ".env"
|| file_name.starts_with(".env.")
|| file_name.ends_with(".key")
|| file_name.ends_with(".pem")
|| file_name.ends_with(".p12")
|| file_name.ends_with(".pfx")
|| file_name.contains("api_key")
|| file_name.contains("apikey")
|| file_name.contains("secret")
|| file_name.contains("token")
|| file_name.contains("credential")
|| matches!(
file_name,
"key" | "keys" | "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519"
)
}

fn ensure_provider_network_allowed(
config: &Config,
profile: &ProviderProfile,
provider_name: &str,
) -> Result<(), String> {
if config.policy.allow_provider_network && profile.network.unwrap_or(true) {
return Ok(());
}

Err(format!(
"Network access denied by security policy for provider '{provider_name}'. Enable allow_provider_network and provider network=true in config to allow live execution."
))
}

fn redact_secrets(content: &str) -> String {
let mut redacted = String::new();
for line in content.lines() {
Expand Down Expand Up @@ -676,6 +712,7 @@ fn build_context_pack(task: &str) -> Result<ContextPack, String> {
|| rel_path.ends_with(".dll")
|| rel_path.ends_with(".pdb")
|| rel_path == "Cargo.lock"
|| is_sensitive_context_path(&rel_path)
{
continue;
}
Expand All @@ -700,6 +737,14 @@ fn build_context_pack(task: &str) -> Result<ContextPack, String> {
".git/".to_string(),
".comptext/".to_string(),
"reports/".to_string(),
".env".to_string(),
".env.*".to_string(),
"*.key".to_string(),
"*.pem".to_string(),
"*.p12".to_string(),
"*.pfx".to_string(),
"*key*".to_string(),
"*credential*".to_string(),
],
allowed_write_paths: vec![],
forbidden_actions: vec![],
Expand Down Expand Up @@ -846,6 +891,8 @@ fn handle_ask(
Ok(())
}
"ollama" => {
ensure_provider_network_allowed(config, profile, resolved_provider)?;

use crate::provider::{OllamaProvider, Provider};
let url = profile
.base_url
Expand Down Expand Up @@ -971,6 +1018,8 @@ fn handle_propose(provider_name: Option<&str>, task: &str, config: &Config) -> R
prov.execute(&request)?
}
"ollama" => {
ensure_provider_network_allowed(config, profile, resolved_provider)?;

use crate::provider::{OllamaProvider, Provider};
let url = profile
.base_url
Expand Down
68 changes: 37 additions & 31 deletions tests/cli_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ fn test_lock() -> MutexGuard<'static, ()> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}

struct FileGuard {
path: std::path::PathBuf,
original: Option<Vec<u8>>,
}

impl FileGuard {
fn new(path: impl Into<std::path::PathBuf>) -> Self {
let path = path.into();
let original = std::fs::read(&path).ok();
Self { path, original }
}
}

impl Drop for FileGuard {
fn drop(&mut self) {
if let Some(ref content) = self.original {
let _ = std::fs::write(&self.path, content);
} else if self.path.exists() {
let _ = std::fs::remove_file(&self.path);
}
}
}

fn run(args: &[&str]) -> String {
let output = Command::new(env!("CARGO_BIN_EXE_ctxt"))
.args(args)
Expand Down Expand Up @@ -60,7 +83,7 @@ fn ask_dummy_provider_succeeds() {
}

#[test]
fn ask_ollama_provider_fails_gracefully_offline() {
fn ask_ollama_provider_respects_network_deny_policy() {
let _guard = test_lock();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_ctxt"))
.args(["ask", "--provider", "ollama-local", "hello"])
Expand All @@ -69,53 +92,47 @@ fn ask_ollama_provider_fails_gracefully_offline() {

assert!(
!output.status.success(),
"command should fail because local Ollama is offline"
"command should fail because network is denied by policy"
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
let stderr_lower = stderr.to_ascii_lowercase();
assert!(
stderr_lower.contains("ollama")
&& (stderr_lower.contains("error:")
|| stderr_lower.contains("failed")
|| stderr_lower.contains("refused")
|| stderr_lower.contains("connection")),
"unexpected stderr from offline ollama run: {stderr}"
stderr_lower.contains("network access denied") && stderr_lower.contains("ollama-local"),
"unexpected stderr from network-denied ollama run: {stderr}"
);
}

#[test]
fn propose_dummy_provider_succeeds() {
let _guard = test_lock();
let slugified_path = std::path::Path::new("proposals/proposal_add_context_inspect.json");
let latest_path = std::path::Path::new("proposals/proposal.latest.json");
let _latest_guard = FileGuard::new(latest_path);
let task = "Smoke temporary proposal";
let slugified_path = std::path::Path::new("proposals/proposal_smoke_temporary_proposal.json");
let _slugified_guard = FileGuard::new(slugified_path);
if slugified_path.exists() {
let _ = std::fs::remove_file(slugified_path);
}
if latest_path.exists() {
let _ = std::fs::remove_file(latest_path);
}

let stdout = run(&["propose", "--provider", "dummy", "Add context inspect"]);
let stdout = run(&["propose", "--provider", "dummy", task]);
assert!(stdout.contains("Proposal generated successfully."));
assert!(stdout.contains("Proposal file: proposals/proposal_add_context_inspect.json"));
assert!(stdout.contains("Proposal file: proposals/proposal_smoke_temporary_proposal.json"));
assert!(stdout.contains("Latest reference: proposals/proposal.latest.json"));

assert!(slugified_path.exists());
assert!(latest_path.exists());

let proposal_content = std::fs::read_to_string(latest_path).unwrap();
assert!(proposal_content.contains("\"task\": \"Add context inspect\""));
assert!(proposal_content.contains("\"task\": \"Smoke temporary proposal\""));
assert!(proposal_content.contains("\"schema_version\": \"0.1\""));
assert!(proposal_content.contains("Mock patch generated by dummy provider:"));

let _ = std::fs::remove_file(slugified_path);
let _ = std::fs::remove_file(latest_path);
}

#[test]
fn apply_and_validate_succeeds() {
let _guard = test_lock();
let mock_file = std::path::Path::new("tests/mock_applied_patch.rs");
let _mock_file_guard = FileGuard::new(mock_file);
std::fs::write(mock_file, "// initial\n").unwrap();

let mock_proposal = serde_json::json!({
Expand All @@ -137,19 +154,13 @@ fn apply_and_validate_succeeds() {
});

let proposal_path = std::path::Path::new("proposals/proposal_test_apply.json");
let _proposal_guard = FileGuard::new(proposal_path);
std::fs::write(
proposal_path,
serde_json::to_string_pretty(&mock_proposal).unwrap(),
)
.unwrap();

let latest_path = std::path::Path::new("proposals/proposal.latest.json");
std::fs::write(
latest_path,
serde_json::to_string_pretty(&mock_proposal).unwrap(),
)
.unwrap();

let stdout_apply = run(&["apply", "--yes", "proposals/proposal_test_apply.json"]);
assert!(stdout_apply.contains("Applying Proposal:"));
assert!(stdout_apply.contains("Proposal applied and validated successfully."));
Expand All @@ -160,10 +171,6 @@ fn apply_and_validate_succeeds() {
let stdout_validate = run(&["validate"]);
assert!(stdout_validate.contains("Standard local validation commands:"));
assert!(stdout_validate.contains("cargo test"));

let _ = std::fs::remove_file(mock_file);
let _ = std::fs::remove_file(proposal_path);
let _ = std::fs::remove_file(latest_path);
}

#[test]
Expand All @@ -188,6 +195,7 @@ fn apply_rejects_disallowed_paths() {
});

let path = std::path::Path::new("proposals/proposal_malicious.json");
let _proposal_guard = FileGuard::new(path);
std::fs::write(path, serde_json::to_string_pretty(&mock_proposal).unwrap()).unwrap();

let output = std::process::Command::new(env!("CARGO_BIN_EXE_ctxt"))
Expand All @@ -201,6 +209,4 @@ fn apply_rejects_disallowed_paths() {
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
assert!(stderr.contains("Security Policy Violation: Path '.env' is not an allowed write path."));

let _ = std::fs::remove_file(path);
}
Loading