Skip to content

Commit 7b35309

Browse files
committed
feat: Improve user experience with errors
1 parent 441d382 commit 7b35309

18 files changed

Lines changed: 267 additions & 158 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
name: AI skipped safe-pkgs
3+
about: Report a case where an AI agent installed packages without calling check_package or check_lockfile first
4+
title: 'AI skipped safe-pkgs: <brief description>'
5+
labels: ai-tool-selection
6+
assignees: ''
7+
8+
---
9+
10+
**Which AI / client were you using?**
11+
e.g. Claude (Sonnet 4.5), GPT-4o, Cursor, Continue, etc.
12+
13+
**What was the prompt or instruction you gave the AI?**
14+
15+
<!-- Paste the exact message or instruction that triggered the package install -->
16+
17+
```
18+
<your prompt here>
19+
```
20+
21+
**What did the AI do instead?**
22+
23+
<!-- Paste the relevant part of the AI's response or actions — e.g. it ran `npm install` directly without calling check_package -->
24+
25+
```
26+
<AI response or tool calls here>
27+
```
28+
29+
**What did you expect it to do?**
30+
31+
<!-- e.g. "Call check_package for each dependency before installing" -->
32+
33+
**MCP / tool configuration**
34+
35+
- How is safe-pkgs registered? (Claude Desktop config, Cursor settings, etc.)
36+
- Paste your MCP server config if relevant:
37+
38+
```json
39+
40+
```
41+
42+
**Additional context**
43+
44+
<!-- Anything else that might help — system prompt, tool list shown to the model, etc. -->

README.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ cargo install --path . --locked
5050
Run MCP server:
5151

5252
```bash
53-
safe-pkgs serve --mcp
53+
safe-pkgs serve
5454
```
5555

5656
Run a one-off audit:
@@ -127,7 +127,7 @@ macOS/Linux:
127127
"safe-pkgs": {
128128
"type": "stdio",
129129
"command": "/path/to/safe-pkgs",
130-
"args": ["serve", "--mcp"]
130+
"args": ["serve"]
131131
}
132132
},
133133
"inputs": []
@@ -258,12 +258,11 @@ For direct dependencies, `dependency_ancestry` is omitted.
258258
- Deterministic policy context: responses include `policy_snapshot_version`, config and policy fingerprints, and enabled check set.
259259
- Local cache: SQLite cache keyed by policy fingerprint + package tuple with TTL expiry.
260260

261-
## Docs Map
261+
## Disclaimer
262262

263-
- Getting started: `docs/getting-started.md`
264-
- Full config schema: `docs/configuration-spec.md`
265-
- Registry check matrix: `docs/check-support-map.md`
266-
- Cache and policy fingerprinting: `docs/cache-deep-dive.md`
263+
`safe-pkgs` works as an MCP tool that AI agents can call before installing packages. However, **we cannot guarantee that an AI agent will always choose to call this tool** — agentic models that autonomously select tools may proceed with package installation without invoking `check_package` or `check_lockfile` first, depending on the model, prompt context, and system prompt configuration.
264+
265+
If your AI agent skipped `safe-pkgs` when it should have called it, please [open an issue](https://github.com/math280h/safe-pkgs/issues/new?template=ai_missed_tool.md) with the prompt and response so we can improve tool descriptions and usage guidance.
267266

268267
## Roadmap
269268

crates/checks/advisory/src/lib.rs

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,12 @@ impl Check for AdvisoryCheck {
4343
&package.latest,
4444
context.advisories,
4545
)
46-
.await
4746
.into_iter()
4847
.collect())
4948
}
5049
}
5150

52-
async fn run(
51+
fn run(
5352
package_name: &str,
5453
requested_version: &str,
5554
latest_version: &str,
@@ -149,39 +148,35 @@ fn best_fixed_version(candidates: &[String]) -> Option<&str> {
149148
mod tests {
150149
use super::*;
151150

152-
#[tokio::test]
153-
async fn empty_advisories_has_no_finding() {
154-
let finding = run("demo", "1.0.0", "1.2.0", &[]).await;
151+
#[test]
152+
fn empty_advisories_has_no_finding() {
153+
let finding = run("demo", "1.0.0", "1.2.0", &[]);
155154
assert!(finding.is_none());
156155
}
157156

158-
#[tokio::test]
159-
async fn advisory_with_cve_alias_and_fixed_version_is_high_risk() {
157+
#[test]
158+
fn advisory_with_cve_alias_and_fixed_version_is_high_risk() {
160159
let advisories = vec![PackageAdvisory {
161160
id: "OSV-123".to_string(),
162161
aliases: vec!["CVE-2025-1234".to_string()],
163162
fixed_versions: vec!["1.1.0".to_string(), "2.0.0".to_string()],
164163
}];
165164

166-
let finding = run("demo", "1.0.0", "2.0.0", &advisories)
167-
.await
168-
.expect("finding");
165+
let finding = run("demo", "1.0.0", "2.0.0", &advisories).expect("finding");
169166
assert_eq!(finding.severity, Severity::High);
170167
assert!(finding.reason.contains("CVE-2025-1234"));
171168
assert!(finding.reason.contains("newer version 1.1.0"));
172169
}
173170

174-
#[tokio::test]
175-
async fn advisory_without_alias_uses_advisory_id() {
171+
#[test]
172+
fn advisory_without_alias_uses_advisory_id() {
176173
let advisories = vec![PackageAdvisory {
177174
id: "OSV-999".to_string(),
178175
aliases: Vec::new(),
179176
fixed_versions: Vec::new(),
180177
}];
181178

182-
let finding = run("demo", "1.0.0", "1.0.0", &advisories)
183-
.await
184-
.expect("finding");
179+
let finding = run("demo", "1.0.0", "1.0.0", &advisories).expect("finding");
185180
assert!(finding.reason.contains("OSV-999"));
186181
}
187182
}

crates/checks/typosquat/src/lib.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ impl Check for TyposquatCheck {
2828
true
2929
}
3030

31+
fn needs_popular_package_names(&self) -> bool {
32+
true
33+
}
34+
3135
async fn run(
3236
&self,
3337
context: &CheckExecutionContext<'_>,
@@ -102,11 +106,15 @@ async fn run(
102106
))
103107
}
104108

109+
/// Computes the Levenshtein distance between two strings, returning `None` early
110+
/// when the distance provably exceeds `max_distance`.
111+
///
112+
/// Package names are ASCII, so byte comparison is both correct and allocation-free.
105113
fn bounded_levenshtein(lhs: &str, rhs: &str, max_distance: usize) -> Option<usize> {
106-
let lhs_chars = lhs.chars().collect::<Vec<_>>();
107-
let rhs_chars = rhs.chars().collect::<Vec<_>>();
108-
let lhs_len = lhs_chars.len();
109-
let rhs_len = rhs_chars.len();
114+
let lhs_bytes = lhs.as_bytes();
115+
let rhs_bytes = rhs.as_bytes();
116+
let lhs_len = lhs_bytes.len();
117+
let rhs_len = rhs_bytes.len();
110118

111119
if lhs_len.abs_diff(rhs_len) > max_distance {
112120
return None;
@@ -115,12 +123,12 @@ fn bounded_levenshtein(lhs: &str, rhs: &str, max_distance: usize) -> Option<usiz
115123
let mut previous = (0..=rhs_len).collect::<Vec<_>>();
116124
let mut current = vec![0usize; rhs_len + 1];
117125

118-
for (i, lhs_char) in lhs_chars.iter().enumerate() {
126+
for (i, &lhs_byte) in lhs_bytes.iter().enumerate() {
119127
current[0] = i + 1;
120128
let mut row_min = current[0];
121129

122-
for (j, rhs_char) in rhs_chars.iter().enumerate() {
123-
let substitution_cost = usize::from(lhs_char != rhs_char);
130+
for (j, &rhs_byte) in rhs_bytes.iter().enumerate() {
131+
let substitution_cost = usize::from(lhs_byte != rhs_byte);
124132
let deletion = previous[j + 1] + 1;
125133
let insertion = current[j] + 1;
126134
let substitution = previous[j] + substitution_cost;

crates/core/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ pub trait Check: Send + Sync {
176176
fn needs_advisories(&self) -> bool {
177177
false
178178
}
179+
fn needs_popular_package_names(&self) -> bool {
180+
false
181+
}
179182
async fn run(
180183
&self,
181184
context: &CheckExecutionContext<'_>,
@@ -606,6 +609,9 @@ pub trait RegistryClient: Send + Sync {
606609
async fn fetch_weekly_downloads(&self, _package: &str) -> Result<Option<u64>, RegistryError> {
607610
Ok(None)
608611
}
612+
async fn prefetch_popular_package_names(&self) -> Result<(), RegistryError> {
613+
Ok(())
614+
}
609615
async fn fetch_popular_package_names(
610616
&self,
611617
_limit: usize,

crates/http/src/lib.rs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub fn build_http_client() -> Client {
3939
.connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
4040
.timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS))
4141
.build()
42-
.unwrap_or_else(|_| Client::new())
42+
.expect("HTTP client construction should not fail with supported TLS and valid timeouts")
4343
}
4444

4545
pub async fn send_with_retry<F>(
@@ -128,18 +128,10 @@ fn compute_retry_delay(
128128
) -> Duration {
129129
let fallback = exponential_backoff(attempt, policy.initial_backoff, policy.max_backoff);
130130
match retry_after {
131-
Some(delay) => {
132-
let bounded = if delay > policy.max_backoff {
133-
policy.max_backoff
134-
} else {
135-
delay
136-
};
137-
if bounded.is_zero() {
138-
Duration::from_millis(1)
139-
} else {
140-
bounded
141-
}
142-
}
131+
// Retry-After is a server directive; respect it exactly rather than capping it.
132+
// Capping can cause immediate rate-limiting on the next attempt.
133+
Some(delay) if delay.is_zero() => Duration::from_millis(1),
134+
Some(delay) => delay,
143135
None => fallback,
144136
}
145137
}
@@ -179,6 +171,20 @@ mod tests {
179171
assert_eq!(delay, Duration::from_secs(2));
180172
}
181173

174+
#[test]
175+
fn compute_retry_delay_respects_retry_after_even_when_larger_than_max_backoff() {
176+
let policy = RetryPolicy {
177+
max_attempts: 3,
178+
initial_backoff: Duration::from_millis(100),
179+
max_backoff: Duration::from_secs(5),
180+
};
181+
182+
// Retry-After: 60 must be honoured; ignoring it and retrying after 5s
183+
// would almost certainly trigger another rate-limit response.
184+
let delay = compute_retry_delay(1, policy, Some(Duration::from_secs(60)));
185+
assert_eq!(delay, Duration::from_secs(60));
186+
}
187+
182188
#[tokio::test]
183189
async fn send_with_retry_retries_retryable_statuses() {
184190
let server = MockServer::start().await;

crates/registry/npm/src/registry.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ use safe_pkgs_registry_http::{
1919
const NPMS_POPULAR_QUERY: &str = "not:deprecated";
2020
const NPMS_PAGE_SIZE: usize = 250;
2121
const NPM_BULK_DOWNLOAD_MAX_PACKAGES: usize = 128;
22+
/// Number of popular packages to warm into the cache during lockfile prefetch.
23+
/// Chosen to match the typosquat check's sample size so subsequent per-package
24+
/// calls always hit the in-process cache.
25+
const POPULAR_PACKAGE_PREFETCH_SIZE: usize = 5000;
2226

2327
#[derive(Clone)]
2428
pub struct NpmRegistryClient {
@@ -125,6 +129,12 @@ impl RegistryClient for NpmRegistryClient {
125129
self.prefetch_weekly_downloads_bulk(packages).await
126130
}
127131

132+
async fn prefetch_popular_package_names(&self) -> Result<(), RegistryError> {
133+
self.fetch_popular_package_names(POPULAR_PACKAGE_PREFETCH_SIZE)
134+
.await
135+
.map(|_| ())
136+
}
137+
128138
async fn fetch_package(&self, package: &str) -> Result<PackageRecord, RegistryError> {
129139
let encoded_name = Self::encode_package_name(package);
130140
let url = format!("{}/{}", self.base_url.trim_end_matches('/'), encoded_name);

src/cache.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,22 @@ impl SqliteCache {
3131
}
3232
let conn = Connection::open(&db_path)
3333
.with_context(|| format!("failed to open sqlite cache at {}", db_path.display()))?;
34-
Self::from_connection(conn, ttl_minutes)
34+
Self::from_connection(conn, Duration::from_secs(ttl_minutes.max(1) * 60))
3535
}
3636

3737
#[cfg(test)]
3838
pub fn in_memory(ttl_minutes: u64) -> anyhow::Result<Self> {
3939
let conn = Connection::open_in_memory().context("failed to open in-memory sqlite cache")?;
40-
Self::from_connection(conn, ttl_minutes)
40+
Self::from_connection(conn, Duration::from_secs(ttl_minutes.max(1) * 60))
4141
}
4242

43-
fn from_connection(conn: Connection, ttl_minutes: u64) -> anyhow::Result<Self> {
43+
#[cfg(test)]
44+
pub fn in_memory_with_ttl(ttl: Duration) -> anyhow::Result<Self> {
45+
let conn = Connection::open_in_memory().context("failed to open in-memory sqlite cache")?;
46+
Self::from_connection(conn, ttl)
47+
}
48+
49+
fn from_connection(conn: Connection, ttl: Duration) -> anyhow::Result<Self> {
4450
conn.execute_batch(
4551
r#"
4652
CREATE TABLE IF NOT EXISTS cache_entries (
@@ -55,7 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at ON cache_entries (expire
5561

5662
Ok(Self {
5763
conn: Mutex::new(conn),
58-
ttl: Duration::from_secs(ttl_minutes.max(1) * 60),
64+
ttl,
5965
})
6066
}
6167

@@ -168,8 +174,8 @@ mod tests {
168174

169175
#[test]
170176
fn expired_entries_are_treated_as_cache_miss() {
171-
let mut cache = SqliteCache::in_memory(1).expect("in-memory cache");
172-
cache.ttl = Duration::from_secs(1);
177+
let cache =
178+
SqliteCache::in_memory_with_ttl(Duration::from_secs(1)).expect("in-memory cache");
173179
cache
174180
.set("expiring-key", "{\"ok\":true}")
175181
.expect("set cache value");
@@ -180,8 +186,8 @@ mod tests {
180186

181187
#[test]
182188
fn set_returns_error_when_ttl_math_overflows() {
183-
let mut cache = SqliteCache::in_memory(1).expect("in-memory cache");
184-
cache.ttl = Duration::from_secs(u64::MAX);
189+
let cache =
190+
SqliteCache::in_memory_with_ttl(Duration::from_secs(u64::MAX)).expect("in-memory cache");
185191
let err = cache
186192
.set("overflow", "{\"ok\":true}")
187193
.expect_err("expected ttl overflow error");

src/checks.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ use crate::types::{Evidence, EvidenceKind};
1919
pub struct CheckDescriptor {
2020
/// Stable check id (for config and support maps).
2121
pub id: CheckId,
22-
/// Human-facing key shown in CLI output.
23-
pub key: &'static str,
2422
/// Short description of what the check does.
2523
pub description: &'static str,
2624
/// Whether the check needs weekly download data.
@@ -36,6 +34,8 @@ pub struct CheckRuntimeRequirements {
3634
pub needs_weekly_downloads: bool,
3735
/// True when at least one enabled check needs advisories.
3836
pub needs_advisories: bool,
37+
/// True when at least one enabled check needs popular package name data.
38+
pub needs_popular_package_names: bool,
3939
}
4040

4141
/// Final result produced by running all enabled checks.
@@ -60,7 +60,6 @@ pub fn check_descriptors() -> Vec<CheckDescriptor> {
6060
.iter()
6161
.map(|check| CheckDescriptor {
6262
id: check.id(),
63-
key: check.id(),
6463
description: check.description(),
6564
needs_weekly_downloads: check.needs_weekly_downloads(),
6665
needs_advisories: check.needs_advisories(),
@@ -85,6 +84,9 @@ pub fn runtime_requirements_for_registry(
8584
CheckRuntimeRequirements {
8685
needs_weekly_downloads: checks.iter().any(|check| check.needs_weekly_downloads()),
8786
needs_advisories: checks.iter().any(|check| check.needs_advisories()),
87+
needs_popular_package_names: checks
88+
.iter()
89+
.any(|check| check.needs_popular_package_names()),
8890
}
8991
.merge(custom_requirements)
9092
}
@@ -276,6 +278,9 @@ pub async fn run_all_checks_at_time(
276278
let requirements = CheckRuntimeRequirements {
277279
needs_weekly_downloads: checks.iter().any(|check| check.needs_weekly_downloads()),
278280
needs_advisories: checks.iter().any(|check| check.needs_advisories()),
281+
needs_popular_package_names: checks
282+
.iter()
283+
.any(|check| check.needs_popular_package_names()),
279284
}
280285
.merge(custom_rules::runtime_requirements_for_registry(
281286
config,
@@ -386,6 +391,7 @@ impl CheckRuntimeRequirements {
386391
Self {
387392
needs_weekly_downloads: self.needs_weekly_downloads || custom.needs_weekly_downloads,
388393
needs_advisories: self.needs_advisories || custom.needs_advisories,
394+
needs_popular_package_names: self.needs_popular_package_names,
389395
}
390396
}
391397
}

0 commit comments

Comments
 (0)