fix: v2.0.1 release — fix broken installs, CI, security, and docs

Critical fixes:
- npm postinstall.js: BINARY_VERSION '1.1.3' → '2.0.1' (every install was 404ing)
- npm package name: corrected error messages to 'vestige-mcp-server'
- README: npm install command pointed to wrong package
- MSRV: bumped from 1.85 to 1.91 (uses floor_char_boundary from 1.91)
- CI: removed stale 'develop' branch from test.yml triggers

Security hardening:
- CSP: restricted connect-src from wildcard 'ws: wss:' to localhost-only
- Added X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy headers
- Added frame-ancestors 'none', base-uri 'self', form-action 'self' to CSP
- Capped retention_distribution endpoint from 10k to 1k nodes
- Added debug logging for WebSocket connections without Origin header

Maintenance:
- All clippy warnings fixed (58 total: redundant closures, collapsible ifs, no-op casts)
- All versions harmonized to 2.0.1 across Cargo.toml and package.json
- CLAUDE.md updated to match v2.0.1 (21 tools, 29 modules, 1238 tests)
- docs/CLAUDE-SETUP.md updated deprecated function names
- License corrected to AGPL-3.0-only in root package.json

1,238 tests passing, 0 clippy warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-03-01 20:20:14 -06:00
parent b03df324da
commit c6090dc2ba
51 changed files with 343 additions and 490 deletions

View file

@ -829,9 +829,10 @@ pub async fn trigger_consolidation(
pub async fn retention_distribution(
State(state): State<AppState>,
) -> Result<Json<Value>, StatusCode> {
// Cap at 1000 to prevent excessive memory usage on large databases
let nodes = state
.storage
.get_all_nodes(10000, 0)
.get_all_nodes(1000, 0)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build distribution buckets

View file

@ -48,22 +48,23 @@ pub fn build_router_with_event_tx(
fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
let origins = vec![
#[allow(unused_mut)]
let mut origins = vec![
format!("http://127.0.0.1:{}", port)
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
format!("http://localhost:{}", port)
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
// SvelteKit dev server
"http://localhost:5173"
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
"http://127.0.0.1:5173"
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
];
// SvelteKit dev server — only in debug builds
#[cfg(debug_assertions)]
{
origins.push("http://localhost:5173".parse::<axum::http::HeaderValue>().expect("valid origin"));
origins.push("http://127.0.0.1:5173".parse::<axum::http::HeaderValue>().expect("valid origin"));
}
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods([
@ -77,11 +78,39 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
axum::http::header::AUTHORIZATION,
]);
// Security: restrict WebSocket connections to localhost only (prevents cross-site WS hijacking)
let csp_value = format!(
"default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' blob: data:; \
connect-src 'self' ws://127.0.0.1:{port} ws://localhost:{port}; \
font-src 'self' data:; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self';"
);
let csp = SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static(
"default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ws: wss:",
),
axum::http::HeaderValue::from_str(&csp_value).expect("valid CSP header"),
);
// Additional security headers
let x_frame_options = SetResponseHeaderLayer::overriding(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
);
let x_content_type_options = SetResponseHeaderLayer::overriding(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
let referrer_policy = SetResponseHeaderLayer::overriding(
axum::http::HeaderName::from_static("referrer-policy"),
axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"),
);
let permissions_policy = SetResponseHeaderLayer::overriding(
axum::http::HeaderName::from_static("permissions-policy"),
axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
let router = Router::new()
@ -121,7 +150,11 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
ServiceBuilder::new()
.concurrency_limit(50)
.layer(cors)
.layer(csp),
.layer(csp)
.layer(x_frame_options)
.layer(x_content_type_options)
.layer(referrer_policy)
.layer(permissions_policy),
)
.with_state(state.clone());

View file

@ -5,6 +5,7 @@
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
@ -15,11 +16,33 @@ use super::events::VestigeEvent;
use super::state::AppState;
/// WebSocket upgrade handler — GET /ws
/// Validates Origin header to prevent cross-site WebSocket hijacking.
pub async fn ws_handler(
headers: HeaderMap,
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
// Validate Origin header (browsers always send it for WebSocket upgrades).
// Non-browser clients (curl, wscat) won't have Origin — allowed since localhost-only.
match headers.get("origin").and_then(|v| v.to_str().ok()) {
Some(origin) => {
let allowed = origin.starts_with("http://127.0.0.1:")
|| origin.starts_with("http://localhost:");
#[cfg(debug_assertions)]
let allowed = allowed || origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173";
if !allowed {
warn!("Rejected WebSocket connection from origin: {}", origin);
return StatusCode::FORBIDDEN.into_response();
}
}
None => {
debug!("WebSocket connection without Origin header (non-browser client)");
}
}
ws.max_frame_size(64 * 1024)
.max_message_size(256 * 1024)
.on_upgrade(move |socket| handle_socket(socket, state))
.into_response()
}
async fn handle_socket(socket: WebSocket, state: AppState) {

View file

@ -850,14 +850,14 @@ impl McpServer {
match tool_name {
// -- smart_ingest: memory created/updated --
"smart_ingest" | "ingest" | "session_checkpoint" => {
// Single mode: result has "action" (created/updated/superseded/reinforced)
if let Some(action) = result.get("action").and_then(|a| a.as_str()) {
// Single mode: result has "decision" (create/update/supersede/reinforce/merge/replace/add_context)
if let Some(decision) = result.get("decision").and_then(|a| a.as_str()) {
let id = result.get("nodeId").or(result.get("id"))
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let preview = result.get("contentPreview").or(result.get("content"))
.and_then(|v| v.as_str()).unwrap_or("").to_string();
match action {
"created" => {
match decision {
"create" => {
let node_type = result.get("nodeType")
.and_then(|v| v.as_str()).unwrap_or("fact").to_string();
let tags = result.get("tags")
@ -868,9 +868,9 @@ impl McpServer {
id, content_preview: preview, node_type, tags, timestamp: now,
});
}
"updated" | "superseded" | "reinforced" => {
"update" | "supersede" | "reinforce" | "merge" | "replace" | "add_context" => {
self.emit(VestigeEvent::MemoryUpdated {
id, content_preview: preview, field: action.to_string(), timestamp: now,
id, content_preview: preview, field: decision.to_string(), timestamp: now,
});
}
_ => {}
@ -879,20 +879,20 @@ impl McpServer {
// Batch mode: result has "results" array
if let Some(results) = result.get("results").and_then(|r| r.as_array()) {
for item in results {
let action = item.get("action").and_then(|a| a.as_str()).unwrap_or("");
let decision = item.get("decision").and_then(|a| a.as_str()).unwrap_or("");
let id = item.get("nodeId").or(item.get("id"))
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let preview = item.get("contentPreview")
.and_then(|v| v.as_str()).unwrap_or("").to_string();
if action == "created" {
if decision == "create" {
self.emit(VestigeEvent::MemoryCreated {
id, content_preview: preview,
node_type: "fact".to_string(), tags: vec![], timestamp: now,
});
} else if !action.is_empty() {
} else if !decision.is_empty() {
self.emit(VestigeEvent::MemoryUpdated {
id, content_preview: preview,
field: action.to_string(), timestamp: now,
field: decision.to_string(), timestamp: now,
});
}
}
@ -1000,7 +1000,7 @@ impl McpServer {
let preview = args.as_ref()
.and_then(|a| a.get("content"))
.and_then(|v| v.as_str())
.map(|s| if s.len() > 100 { format!("{}...", &s[..100]) } else { s.to_string() })
.map(|s| if s.len() > 100 { format!("{}...", &s[..s.floor_char_boundary(100)]) } else { s.to_string() })
.unwrap_or_default();
let composite = result.get("compositeScore")
.or(result.get("composite_score"))

View file

@ -72,10 +72,10 @@ pub async fn execute(
if let Some(ref memory_id) = args.memory_id {
// Per-memory mode: state transitions for a specific memory
execute_per_memory(&storage, memory_id, limit)
execute_per_memory(storage, memory_id, limit)
} else {
// System-wide mode: consolidations + recent transitions
execute_system_wide(&storage, limit)
execute_system_wide(storage, limit)
}
}

View file

@ -204,7 +204,7 @@ async fn execute_remember_decision(
// Build content with structured format (ADR-like)
let mut content = format!(
"# Decision: {}\n\n## Context\n\n{}\n\n## Decision\n\n{}",
&decision[..decision.len().min(50)],
&decision[..decision.floor_char_boundary(50)],
rationale,
decision
);

View file

@ -219,7 +219,7 @@ pub async fn execute(
.map(|n| {
let c = n.content.replace('\n', " ");
if c.len() > 120 {
format!("{}...", &c[..120])
format!("{}...", &c[..c.floor_char_boundary(120)])
} else {
c
}

View file

@ -96,7 +96,7 @@ pub async fn execute(
if intent_result.confidence > 0.5 {
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
let intent_tag = if intent_tag.len() > 50 {
format!("{}...", &intent_tag[..47])
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
} else {
intent_tag
};

View file

@ -297,7 +297,7 @@ async fn execute_set(
if intent_result.confidence > 0.5 {
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
let intent_tag = if intent_tag.len() > 50 {
format!("{}...", &intent_tag[..47])
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
} else {
intent_tag
};

View file

@ -249,7 +249,7 @@ pub async fn execute_system_status(
let last_dream = storage.get_last_dream().ok().flatten();
let saves_since_last_dream = match &last_dream {
Some(dt) => storage.count_memories_since(*dt).unwrap_or(0),
None => stats.total_nodes as i64,
None => stats.total_nodes,
};
let last_backup = Storage::get_last_backup_timestamp();

View file

@ -37,8 +37,10 @@ pub mod session_context;
pub mod health;
pub mod graph;
// Deprecated tools - kept for internal backwards compatibility
// These modules are intentionally unused in the public API
// Deprecated/internal tools — not advertised in the public MCP tools/list,
// but some functions are actively dispatched for backwards compatibility
// and internal cognitive operations. #[allow(dead_code)] suppresses warnings
// for the unused schema/struct items within these modules.
#[allow(dead_code)]
pub mod checkpoint;
#[allow(dead_code)]

View file

@ -218,7 +218,7 @@ pub async fn execute(
let last_dream = storage.get_last_dream().ok().flatten();
let saves_since_last_dream = match &last_dream {
Some(dt) => storage.count_memories_since(*dt).unwrap_or(0),
None => stats.total_nodes as i64,
None => stats.total_nodes,
};
let last_backup = Storage::get_last_backup_timestamp();
let now = Utc::now();
@ -333,8 +333,8 @@ pub async fn execute(
// ====================================================================
// 5. Codebase patterns/decisions (if codebase specified)
// ====================================================================
if let Some(ref ctx) = args.context {
if let Some(ref codebase) = ctx.codebase {
if let Some(ref ctx) = args.context
&& let Some(ref codebase) = ctx.codebase {
let codebase_tag = format!("codebase:{}", codebase);
let mut cb_lines: Vec<String> = Vec::new();
@ -368,7 +368,6 @@ pub async fn execute(
context_parts.push(format!("**Codebase ({}):**\n{}", codebase, cb_lines.join("\n")));
}
}
}
// ====================================================================
// 6. Assemble final response
@ -404,11 +403,10 @@ fn check_intention_triggered(
match trigger.trigger_type.as_deref() {
Some("time") => {
if let Some(ref at) = trigger.at {
if let Ok(trigger_time) = DateTime::parse_from_rfc3339(at) {
if let Some(ref at) = trigger.at
&& let Ok(trigger_time) = DateTime::parse_from_rfc3339(at) {
return trigger_time.with_timezone(&Utc) <= now;
}
}
if let Some(mins) = trigger.in_minutes {
let trigger_time = intention.created_at + Duration::minutes(mins);
return trigger_time <= now;
@ -418,29 +416,25 @@ fn check_intention_triggered(
Some("context") => {
// Check codebase match
if let (Some(trigger_cb), Some(current_cb)) = (&trigger.codebase, &ctx.codebase)
{
if current_cb
&& current_cb
.to_lowercase()
.contains(&trigger_cb.to_lowercase())
{
return true;
}
}
// Check file pattern match
if let (Some(pattern), Some(file)) = (&trigger.file_pattern, &ctx.file) {
if file.contains(pattern.as_str()) {
if let (Some(pattern), Some(file)) = (&trigger.file_pattern, &ctx.file)
&& file.contains(pattern.as_str()) {
return true;
}
}
// Check topic match
if let (Some(topic), Some(topics)) = (&trigger.topic, &ctx.topics) {
if topics
if let (Some(topic), Some(topics)) = (&trigger.topic, &ctx.topics)
&& topics
.iter()
.any(|t| t.to_lowercase().contains(&topic.to_lowercase()))
{
return true;
}
}
false
}
_ => false,

View file

@ -165,7 +165,7 @@ pub async fn execute(
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
// Truncate long intent tags
let intent_tag = if intent_tag.len() > 50 {
format!("{}...", &intent_tag[..47])
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
} else {
intent_tag
};
@ -338,7 +338,7 @@ async fn execute_batch(
if intent_result.confidence > 0.5 {
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
let intent_tag = if intent_tag.len() > 50 {
format!("{}...", &intent_tag[..47])
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
} else {
intent_tag
};