anveesa 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +2 -2
- package/package.json +1 -1
- package/src/cli.rs +20 -0
- package/src/lib.rs +527 -93
- package/src/provider/openai_compatible.rs +47 -1
- package/src/tools.rs +79 -22
- package/src/tools_scenarios.rs +1693 -0
|
@@ -105,7 +105,10 @@ pub async fn ask(
|
|
|
105
105
|
usage_requested = false;
|
|
106
106
|
continue;
|
|
107
107
|
}
|
|
108
|
-
bail!(
|
|
108
|
+
bail!(
|
|
109
|
+
"provider '{provider_name}' HTTP {status}: {}",
|
|
110
|
+
extract_api_error(&response_body)
|
|
111
|
+
);
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
let mut state = StreamState::default();
|
|
@@ -1017,6 +1020,49 @@ fn is_stream_options_error(body: &str) -> bool {
|
|
|
1017
1020
|
lower.contains("stream_options") || lower.contains("include_usage")
|
|
1018
1021
|
}
|
|
1019
1022
|
|
|
1023
|
+
/// Extract a concise, human-readable error message from a provider HTTP error body.
|
|
1024
|
+
/// Parses `{"error":{"message":"..."}}`, strips verbose class prefixes (e.g. litellm.*),
|
|
1025
|
+
/// takes only the first line, and truncates to 120 chars.
|
|
1026
|
+
fn extract_api_error(body: &str) -> String {
|
|
1027
|
+
// Try to pull error.message out of the JSON
|
|
1028
|
+
let extracted = serde_json::from_str::<Value>(body)
|
|
1029
|
+
.ok()
|
|
1030
|
+
.and_then(|v| {
|
|
1031
|
+
v.pointer("/error/message")
|
|
1032
|
+
.or_else(|| v.get("message"))
|
|
1033
|
+
.and_then(|m| m.as_str())
|
|
1034
|
+
.map(str::to_string)
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
let raw = extracted.as_deref().unwrap_or(body);
|
|
1038
|
+
|
|
1039
|
+
// First line only
|
|
1040
|
+
let line = raw.lines().next().unwrap_or(raw).trim();
|
|
1041
|
+
|
|
1042
|
+
// Strip a leading "Namespace.ErrorClass: " or "ClassName: " prefix once
|
|
1043
|
+
// (covers litellm.BadRequestError, OpenAIException, etc.)
|
|
1044
|
+
let stripped = if let Some(colon) = line.find(": ") {
|
|
1045
|
+
let prefix = &line[..colon];
|
|
1046
|
+
if !prefix.is_empty()
|
|
1047
|
+
&& prefix
|
|
1048
|
+
.chars()
|
|
1049
|
+
.all(|c| c.is_alphanumeric() || c == '.' || c == '_')
|
|
1050
|
+
{
|
|
1051
|
+
line[colon + 2..].trim_start()
|
|
1052
|
+
} else {
|
|
1053
|
+
line
|
|
1054
|
+
}
|
|
1055
|
+
} else {
|
|
1056
|
+
line
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
if stripped.len() > 120 {
|
|
1060
|
+
format!("{}…", &stripped[..120])
|
|
1061
|
+
} else {
|
|
1062
|
+
stripped.to_string()
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1020
1066
|
#[cfg(test)]
|
|
1021
1067
|
mod tests {
|
|
1022
1068
|
use super::*;
|
package/src/tools.rs
CHANGED
|
@@ -3,6 +3,7 @@ use std::{
|
|
|
3
3
|
fs,
|
|
4
4
|
path::{Path, PathBuf},
|
|
5
5
|
process::Stdio,
|
|
6
|
+
sync::OnceLock,
|
|
6
7
|
time::Duration,
|
|
7
8
|
};
|
|
8
9
|
|
|
@@ -303,6 +304,7 @@ async fn list_dir(arguments: &str) -> Result<Value> {
|
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
let mut entries = Vec::new();
|
|
307
|
+
let mut truncated = false;
|
|
306
308
|
for entry in
|
|
307
309
|
fs::read_dir(&path).with_context(|| format!("failed to read {}", path.display()))?
|
|
308
310
|
{
|
|
@@ -319,6 +321,7 @@ async fn list_dir(arguments: &str) -> Result<Value> {
|
|
|
319
321
|
"kind": path_kind(&entry_path),
|
|
320
322
|
}));
|
|
321
323
|
if entries.len() >= MAX_DIR_ENTRIES {
|
|
324
|
+
truncated = true;
|
|
322
325
|
break;
|
|
323
326
|
}
|
|
324
327
|
}
|
|
@@ -326,7 +329,8 @@ async fn list_dir(arguments: &str) -> Result<Value> {
|
|
|
326
329
|
Ok(json!({
|
|
327
330
|
"ok": true,
|
|
328
331
|
"path": path.display().to_string(),
|
|
329
|
-
"entries": entries
|
|
332
|
+
"entries": entries,
|
|
333
|
+
"truncated": truncated,
|
|
330
334
|
}))
|
|
331
335
|
}
|
|
332
336
|
|
|
@@ -339,6 +343,7 @@ async fn find_files(arguments: &str) -> Result<Value> {
|
|
|
339
343
|
}
|
|
340
344
|
|
|
341
345
|
let mut results = Vec::new();
|
|
346
|
+
let mut truncated = false;
|
|
342
347
|
walk_paths(&root, |path| {
|
|
343
348
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
344
349
|
return Ok(true);
|
|
@@ -348,15 +353,20 @@ async fn find_files(arguments: &str) -> Result<Value> {
|
|
|
348
353
|
"path": path.display().to_string(),
|
|
349
354
|
"kind": path_kind(path),
|
|
350
355
|
}));
|
|
356
|
+
if results.len() >= MAX_SEARCH_RESULTS {
|
|
357
|
+
truncated = true;
|
|
358
|
+
return Ok(false);
|
|
359
|
+
}
|
|
351
360
|
}
|
|
352
|
-
Ok(
|
|
361
|
+
Ok(true)
|
|
353
362
|
})?;
|
|
354
363
|
|
|
355
364
|
Ok(json!({
|
|
356
365
|
"ok": true,
|
|
357
366
|
"root": root.display().to_string(),
|
|
358
367
|
"query": args.query,
|
|
359
|
-
"results": results
|
|
368
|
+
"results": results,
|
|
369
|
+
"truncated": truncated,
|
|
360
370
|
}))
|
|
361
371
|
}
|
|
362
372
|
|
|
@@ -369,6 +379,7 @@ async fn search_text(arguments: &str) -> Result<Value> {
|
|
|
369
379
|
}
|
|
370
380
|
|
|
371
381
|
let mut results = Vec::new();
|
|
382
|
+
let mut truncated = false;
|
|
372
383
|
walk_paths(&root, |path| {
|
|
373
384
|
if !path.is_file() || is_sensitive_path(path) || !is_small_text_candidate(path) {
|
|
374
385
|
return Ok(true);
|
|
@@ -377,29 +388,29 @@ async fn search_text(arguments: &str) -> Result<Value> {
|
|
|
377
388
|
let Ok(content) = fs::read_to_string(path) else {
|
|
378
389
|
return Ok(true);
|
|
379
390
|
};
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}));
|
|
391
|
+
for (i, line) in content.lines().enumerate() {
|
|
392
|
+
if line.to_lowercase().contains(&query) {
|
|
393
|
+
results.push(json!({
|
|
394
|
+
"path": path.display().to_string(),
|
|
395
|
+
"line": i + 1,
|
|
396
|
+
"preview": truncate(line.trim(), 240),
|
|
397
|
+
}));
|
|
398
|
+
if results.len() >= MAX_SEARCH_RESULTS {
|
|
399
|
+
truncated = true;
|
|
400
|
+
return Ok(false);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
393
403
|
}
|
|
394
404
|
|
|
395
|
-
Ok(
|
|
405
|
+
Ok(true)
|
|
396
406
|
})?;
|
|
397
407
|
|
|
398
408
|
Ok(json!({
|
|
399
409
|
"ok": true,
|
|
400
410
|
"root": root.display().to_string(),
|
|
401
411
|
"query": args.query,
|
|
402
|
-
"results": results
|
|
412
|
+
"results": results,
|
|
413
|
+
"truncated": truncated,
|
|
403
414
|
}))
|
|
404
415
|
}
|
|
405
416
|
|
|
@@ -441,6 +452,17 @@ async fn read_file(arguments: &str) -> Result<Value> {
|
|
|
441
452
|
}))
|
|
442
453
|
}
|
|
443
454
|
|
|
455
|
+
fn http_client() -> &'static reqwest::Client {
|
|
456
|
+
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
|
457
|
+
CLIENT.get_or_init(|| {
|
|
458
|
+
reqwest::Client::builder()
|
|
459
|
+
.timeout(Duration::from_secs(10))
|
|
460
|
+
.user_agent(concat!("anveesa-cli/", env!("CARGO_PKG_VERSION")))
|
|
461
|
+
.build()
|
|
462
|
+
.expect("failed to build HTTP client")
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
444
466
|
async fn web_search(arguments: &str) -> Result<Value> {
|
|
445
467
|
let args: WebSearchArgs = parse_args(arguments)?;
|
|
446
468
|
let query = args.query.trim();
|
|
@@ -452,9 +474,8 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
452
474
|
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
|
|
453
475
|
percent_encode(query)
|
|
454
476
|
);
|
|
455
|
-
let response: Value =
|
|
477
|
+
let response: Value = http_client()
|
|
456
478
|
.get(&url)
|
|
457
|
-
.header("User-Agent", "anveesa-cli/0.1")
|
|
458
479
|
.send()
|
|
459
480
|
.await
|
|
460
481
|
.context("web search request failed")?
|
|
@@ -592,6 +613,7 @@ async fn run_command(arguments: &str) -> Result<Value> {
|
|
|
592
613
|
.stdin(Stdio::null())
|
|
593
614
|
.stdout(Stdio::piped())
|
|
594
615
|
.stderr(Stdio::piped())
|
|
616
|
+
.kill_on_drop(true)
|
|
595
617
|
.spawn()
|
|
596
618
|
.context("failed to spawn command")?;
|
|
597
619
|
|
|
@@ -772,16 +794,34 @@ fn is_small_text_candidate(path: &Path) -> bool {
|
|
|
772
794
|
|
|
773
795
|
fn is_sensitive_path(path: &Path) -> bool {
|
|
774
796
|
let lower = path.display().to_string().to_lowercase();
|
|
797
|
+
// Credential directories
|
|
775
798
|
lower.contains("/.ssh/")
|
|
776
799
|
|| lower.contains("/.aws/")
|
|
777
800
|
|| lower.contains("/.gnupg/")
|
|
801
|
+
|| lower.contains("/.kube/")
|
|
802
|
+
|| lower.contains("/.docker/")
|
|
803
|
+
// Environment and secret files
|
|
778
804
|
|| lower.ends_with("/.env")
|
|
779
805
|
|| lower.contains("/.env.")
|
|
806
|
+
// SSH private key filenames
|
|
780
807
|
|| lower.ends_with("/id_rsa")
|
|
781
808
|
|| lower.ends_with("/id_dsa")
|
|
782
809
|
|| lower.ends_with("/id_ed25519")
|
|
810
|
+
|| lower.ends_with("/id_ecdsa")
|
|
811
|
+
// Cloud/tool credential files
|
|
783
812
|
|| lower.ends_with("/credentials")
|
|
784
|
-
|| lower.
|
|
813
|
+
|| lower.ends_with("/.netrc")
|
|
814
|
+
|| lower.ends_with("/.npmrc")
|
|
815
|
+
|| lower.ends_with("/.pypirc")
|
|
816
|
+
|| lower.ends_with("/.git-credentials")
|
|
817
|
+
// System auth files
|
|
818
|
+
|| lower.ends_with("/etc/shadow")
|
|
819
|
+
|| lower.ends_with("/etc/passwd")
|
|
820
|
+
// Targeted secret patterns (narrower than a broad "secret" substring)
|
|
821
|
+
|| lower.contains("secret_key")
|
|
822
|
+
|| lower.contains("secretkey")
|
|
823
|
+
|| lower.contains("/secrets.")
|
|
824
|
+
|| lower.contains("/secrets/")
|
|
785
825
|
|| lower.contains("private_key")
|
|
786
826
|
}
|
|
787
827
|
|
|
@@ -912,10 +952,23 @@ mod tests {
|
|
|
912
952
|
|
|
913
953
|
#[test]
|
|
914
954
|
fn flags_sensitive_paths() {
|
|
955
|
+
// Original cases
|
|
915
956
|
assert!(is_sensitive_path(Path::new("/home/u/.ssh/id_rsa")));
|
|
916
957
|
assert!(is_sensitive_path(Path::new("/proj/.env")));
|
|
917
|
-
|
|
958
|
+
// New credential directories
|
|
959
|
+
assert!(is_sensitive_path(Path::new("/home/u/.kube/config")));
|
|
960
|
+
assert!(is_sensitive_path(Path::new("/home/u/.docker/config.json")));
|
|
961
|
+
assert!(is_sensitive_path(Path::new("/home/u/.git-credentials")));
|
|
962
|
+
assert!(is_sensitive_path(Path::new("/home/u/.netrc")));
|
|
963
|
+
assert!(is_sensitive_path(Path::new("/home/u/.npmrc")));
|
|
964
|
+
// Targeted secret patterns
|
|
965
|
+
assert!(is_sensitive_path(Path::new("/proj/config/secrets.yaml")));
|
|
966
|
+
assert!(is_sensitive_path(Path::new("/proj/secrets/db.json")));
|
|
967
|
+
assert!(is_sensitive_path(Path::new("/proj/config/secret_key.txt")));
|
|
968
|
+
// Non-sensitive paths — including the false-positive the old "secret" check caused
|
|
918
969
|
assert!(!is_sensitive_path(Path::new("/proj/src/main.rs")));
|
|
970
|
+
assert!(!is_sensitive_path(Path::new("/proj/src/secret_manager.rs")));
|
|
971
|
+
assert!(!is_sensitive_path(Path::new("/proj/docs/secret_rotation.md")));
|
|
919
972
|
}
|
|
920
973
|
|
|
921
974
|
#[test]
|
|
@@ -1033,3 +1086,7 @@ mod tests {
|
|
|
1033
1086
|
assert_eq!(result["exit_code"], json!(3));
|
|
1034
1087
|
}
|
|
1035
1088
|
}
|
|
1089
|
+
|
|
1090
|
+
#[cfg(test)]
|
|
1091
|
+
#[path = "tools_scenarios.rs"]
|
|
1092
|
+
mod scenarios;
|