From 74d0b9d194ac747c3d1ba5cad3cc825b2ec208ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20K=C3=B6lnberger?= <159939812+ProfRandom92@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:58:37 -0700 Subject: [PATCH 1/2] Repair MVP release blockers --- .github/workflows/ci.yml | 2 ++ README.md | 5 --- src/cli.rs | 43 +++++++++++++++++++++++++ tests/cli_smoke.rs | 68 ++++++++++++++++++++++------------------ 4 files changed, 82 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56d4c54..95df336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 79c6273..8808ce9 100644 --- a/README.md +++ b/README.md @@ -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 "..." diff --git a/src/cli.rs b/src/cli.rs index e924ed5..2b02db2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -630,6 +630,36 @@ 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("key") + || file_name.contains("credential") + || matches!(file_name, "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() { @@ -676,6 +706,7 @@ fn build_context_pack(task: &str) -> Result { || rel_path.ends_with(".dll") || rel_path.ends_with(".pdb") || rel_path == "Cargo.lock" + || is_sensitive_context_path(&rel_path) { continue; } @@ -700,6 +731,14 @@ fn build_context_pack(task: &str) -> Result { ".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![], @@ -846,6 +885,8 @@ fn handle_ask( Ok(()) } "ollama" => { + ensure_provider_network_allowed(config, profile, resolved_provider)?; + use crate::provider::{OllamaProvider, Provider}; let url = profile .base_url @@ -971,6 +1012,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 diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 6933abe..71606a9 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -10,6 +10,29 @@ fn test_lock() -> MutexGuard<'static, ()> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } +struct FileGuard { + path: std::path::PathBuf, + original: Option>, +} + +impl FileGuard { + fn new(path: impl Into) -> 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) @@ -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"]) @@ -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!({ @@ -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.")); @@ -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] @@ -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")) @@ -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); } From 4678344cdfced0c6b194017989580a9de855116f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20K=C3=B6lnberger?= <159939812+ProfRandom92@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:32:16 -0700 Subject: [PATCH 2/2] Narrow sensitive context path matching --- src/cli.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2b02db2..fcb8e2a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -641,9 +641,15 @@ fn is_sensitive_context_path(path: &str) -> bool { || file_name.ends_with(".pem") || file_name.ends_with(".p12") || file_name.ends_with(".pfx") - || file_name.contains("key") + || file_name.contains("api_key") + || file_name.contains("apikey") + || file_name.contains("secret") + || file_name.contains("token") || file_name.contains("credential") - || matches!(file_name, "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519") + || matches!( + file_name, + "key" | "keys" | "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519" + ) } fn ensure_provider_network_allowed(