fix: resolve medium/low audit findings across backend and frontend

- ctx CLI: validate int() limit arg, wrap FTS5 MATCH in try/except
- ctx.rs: FTS5 error message clarity, Mutex::lock() returns Err not panic
- sdk-messages.ts: runtime type guards (str/num) replace bare `as` casts
- agent-runner.ts: strip ANTHROPIC_* env vars alongside CLAUDE*
- agent-dispatcher.ts: timestamps use seconds (match session.rs convention)
- remote.rs: disconnect handler uses lock().await not try_lock()
- session.rs: propagate pane_ids serialization error
- watcher.rs: reject root-level paths instead of silent no-op
- lib.rs: log warnings on profile.toml read failure and resource_dir error
- agent-bridge.ts: validate event payload is object before cast
This commit is contained in:
Hibryda 2026-03-08 20:10:54 +01:00
parent 044f891c3a
commit 3f1638c98b
10 changed files with 97 additions and 57 deletions

30
ctx
View file

@ -278,7 +278,11 @@ def cmd_history(args):
print("Usage: ctx history <project> [limit]") print("Usage: ctx history <project> [limit]")
sys.exit(1) sys.exit(1)
project = args[0] project = args[0]
limit = int(args[1]) if len(args) > 1 else 10 try:
limit = int(args[1]) if len(args) > 1 else 10
except ValueError:
print(f"Error: limit must be an integer, got '{args[1]}'")
sys.exit(1)
db = get_db() db = get_db()
rows = db.execute( rows = db.execute(
"SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?", "SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?",
@ -301,16 +305,24 @@ def cmd_search(args):
query = " ".join(args) query = " ".join(args)
db = get_db() db = get_db()
# Search project contexts # Search project contexts (FTS5 MATCH can fail on malformed query syntax)
results_ctx = db.execute( try:
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?", results_ctx = db.execute(
(query,), "SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
).fetchall() (query,),
).fetchall()
except sqlite3.OperationalError:
print(f"Invalid search query: '{query}' (FTS5 syntax error)")
db.close()
sys.exit(1)
# Search shared contexts # Search shared contexts
results_shared = db.execute( try:
"SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,) results_shared = db.execute(
).fetchall() "SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,)
).fetchall()
except sqlite3.OperationalError:
results_shared = []
# Search summaries (simple LIKE since no FTS on summaries) # Search summaries (simple LIKE since no FTS on summaries)
results_sum = db.execute( results_sum = db.execute(

View file

@ -86,7 +86,7 @@ async function handleQuery(msg: QueryMessage) {
// Strip CLAUDE* env vars to prevent nesting detection by the spawned CLI // Strip CLAUDE* env vars to prevent nesting detection by the spawned CLI
const cleanEnv: Record<string, string | undefined> = {}; const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(process.env)) { for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('CLAUDE')) { if (!key.startsWith('CLAUDE') && !key.startsWith('ANTHROPIC_')) {
cleanEnv[key] = value; cleanEnv[key] = value;
} }
} }

View file

@ -127,7 +127,7 @@ impl CtxDb {
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
).map_err(|e| format!("Failed to reopen database: {e}"))?; ).map_err(|e| format!("Failed to reopen database: {e}"))?;
let mut lock = self.conn.lock().unwrap(); let mut lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
*lock = Some(ro_conn); *lock = Some(ro_conn);
Ok(()) Ok(())
@ -149,7 +149,7 @@ impl CtxDb {
} }
pub fn get_context(&self, project: &str) -> Result<Vec<CtxEntry>, String> { pub fn get_context(&self, project: &str) -> Result<Vec<CtxEntry>, String> {
let lock = self.conn.lock().unwrap(); let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?; let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn let mut stmt = conn
@ -173,7 +173,7 @@ impl CtxDb {
} }
pub fn get_shared(&self) -> Result<Vec<CtxEntry>, String> { pub fn get_shared(&self) -> Result<Vec<CtxEntry>, String> {
let lock = self.conn.lock().unwrap(); let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?; let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn let mut stmt = conn
@ -197,7 +197,7 @@ impl CtxDb {
} }
pub fn get_summaries(&self, project: &str, limit: i64) -> Result<Vec<CtxSummary>, String> { pub fn get_summaries(&self, project: &str, limit: i64) -> Result<Vec<CtxSummary>, String> {
let lock = self.conn.lock().unwrap(); let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?; let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn let mut stmt = conn
@ -220,7 +220,7 @@ impl CtxDb {
} }
pub fn search(&self, query: &str) -> Result<Vec<CtxEntry>, String> { pub fn search(&self, query: &str) -> Result<Vec<CtxEntry>, String> {
let lock = self.conn.lock().unwrap(); let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?; let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn let mut stmt = conn
@ -236,7 +236,14 @@ impl CtxDb {
updated_at: String::new(), // FTS5 virtual table doesn't store updated_at updated_at: String::new(), // FTS5 virtual table doesn't store updated_at
}) })
}) })
.map_err(|e| format!("ctx search failed: {e}"))? .map_err(|e| {
let msg = e.to_string();
if msg.contains("fts5") || msg.contains("syntax") {
format!("Invalid search query syntax: {e}")
} else {
format!("ctx search failed: {e}")
}
})?
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("ctx row read failed: {e}"))?; .map_err(|e| format!("ctx row read failed: {e}"))?;

View file

@ -240,7 +240,10 @@ fn claude_list_profiles() -> Vec<ClaudeProfile> {
// Read profile.toml for metadata // Read profile.toml for metadata
let toml_path = entry.path().join("profile.toml"); let toml_path = entry.path().join("profile.toml");
let (email, subscription_type, display_name) = if toml_path.exists() { let (email, subscription_type, display_name) = if toml_path.exists() {
let content = std::fs::read_to_string(&toml_path).unwrap_or_default(); let content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
log::warn!("Failed to read {}: {e}", toml_path.display());
String::new()
});
( (
extract_toml_value(&content, "email"), extract_toml_value(&content, "email"),
extract_toml_value(&content, "subscription_type"), extract_toml_value(&content, "subscription_type"),
@ -606,7 +609,10 @@ pub fn run() {
.handle() .handle()
.path() .path()
.resource_dir() .resource_dir()
.unwrap_or_default(); .unwrap_or_else(|e| {
log::warn!("Failed to resolve resource_dir: {e}");
std::path::PathBuf::new()
});
let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap() .unwrap()

View file

@ -226,11 +226,10 @@ impl RemoteManager {
// Mark disconnected and clear connection // Mark disconnected and clear connection
{ {
if let Ok(mut machines) = machines_ref.try_lock() { let mut machines = machines_ref.lock().await;
if let Some(machine) = machines.get_mut(&mid) { if let Some(machine) = machines.get_mut(&mid) {
machine.status = "disconnected".to_string(); machine.status = "disconnected".to_string();
machine.connection = None; machine.connection = None;
}
} }
} }
let _ = app_handle.emit("remote-machine-disconnected", &serde_json::json!({ let _ = app_handle.emit("remote-machine-disconnected", &serde_json::json!({

View file

@ -284,7 +284,8 @@ impl SessionDb {
pub fn save_layout(&self, layout: &LayoutState) -> Result<(), String> { pub fn save_layout(&self, layout: &LayoutState) -> Result<(), String> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let pane_ids_json = serde_json::to_string(&layout.pane_ids).unwrap_or_default(); let pane_ids_json = serde_json::to_string(&layout.pane_ids)
.map_err(|e| format!("Serialize pane_ids failed: {e}"))?;
conn.execute( conn.execute(
"UPDATE layout_state SET preset = ?1, pane_ids = ?2 WHERE id = 1", "UPDATE layout_state SET preset = ?1, pane_ids = ?2 WHERE id = 1",
params![layout.preset, pane_ids_json], params![layout.preset, pane_ids_json],

View file

@ -72,8 +72,10 @@ impl FileWatcherManager {
) )
.map_err(|e| format!("Failed to create watcher: {e}"))?; .map_err(|e| format!("Failed to create watcher: {e}"))?;
let watch_dir = file_path.parent()
.ok_or_else(|| format!("Cannot watch root-level path: {path}"))?;
watcher watcher
.watch(file_path.parent().unwrap_or(&file_path), RecursiveMode::NonRecursive) .watch(watch_dir, RecursiveMode::NonRecursive)
.map_err(|e| format!("Failed to watch path: {e}"))?; .map_err(|e| format!("Failed to watch path: {e}"))?;
let mut watchers = self.watchers.lock().unwrap(); let mut watchers = self.watchers.lock().unwrap();

View file

@ -56,7 +56,9 @@ export async function onSidecarMessage(
callback: (msg: SidecarMessage) => void, callback: (msg: SidecarMessage) => void,
): Promise<UnlistenFn> { ): Promise<UnlistenFn> {
return listen<SidecarMessage>('sidecar-message', (event) => { return listen<SidecarMessage>('sidecar-message', (event) => {
callback(event.payload as SidecarMessage); const payload = event.payload;
if (typeof payload !== 'object' || payload === null) return;
callback(payload as SidecarMessage);
}); });
} }

View file

@ -66,14 +66,24 @@ export interface ErrorContent {
message: string; message: string;
} }
/** Runtime guard — returns value if it's a string, fallback otherwise */
function str(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
/** Runtime guard — returns value if it's a number, fallback otherwise */
function num(v: unknown, fallback = 0): number {
return typeof v === 'number' ? v : fallback;
}
/** /**
* Adapt a raw SDK stream-json message to our internal format. * Adapt a raw SDK stream-json message to our internal format.
* When SDK changes wire format, only this function needs updating. * When SDK changes wire format, only this function needs updating.
*/ */
export function adaptSDKMessage(raw: Record<string, unknown>): AgentMessage[] { export function adaptSDKMessage(raw: Record<string, unknown>): AgentMessage[] {
const uuid = (raw.uuid as string) ?? crypto.randomUUID(); const uuid = str(raw.uuid) || crypto.randomUUID();
const timestamp = Date.now(); const timestamp = Date.now();
const parentId = raw.parent_tool_use_id as string | undefined; const parentId = typeof raw.parent_tool_use_id === 'string' ? raw.parent_tool_use_id : undefined;
switch (raw.type) { switch (raw.type) {
case 'system': case 'system':
@ -99,17 +109,17 @@ function adaptSystemMessage(
uuid: string, uuid: string,
timestamp: number, timestamp: number,
): AgentMessage[] { ): AgentMessage[] {
const subtype = raw.subtype as string; const subtype = str(raw.subtype);
if (subtype === 'init') { if (subtype === 'init') {
return [{ return [{
id: uuid, id: uuid,
type: 'init', type: 'init',
content: { content: {
sessionId: raw.session_id as string, sessionId: str(raw.session_id),
model: raw.model as string, model: str(raw.model),
cwd: raw.cwd as string, cwd: str(raw.cwd),
tools: (raw.tools as string[]) ?? [], tools: Array.isArray(raw.tools) ? raw.tools.filter((t): t is string => typeof t === 'string') : [],
} satisfies InitContent, } satisfies InitContent,
timestamp, timestamp,
}]; }];
@ -120,7 +130,7 @@ function adaptSystemMessage(
type: 'status', type: 'status',
content: { content: {
subtype, subtype,
message: raw.status as string | undefined, message: typeof raw.status === 'string' ? raw.status : undefined,
} satisfies StatusContent, } satisfies StatusContent,
timestamp, timestamp,
}]; }];
@ -133,11 +143,11 @@ function adaptAssistantMessage(
parentId?: string, parentId?: string,
): AgentMessage[] { ): AgentMessage[] {
const messages: AgentMessage[] = []; const messages: AgentMessage[] = [];
const msg = raw.message as Record<string, unknown> | undefined; const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record<string, unknown> : undefined;
if (!msg) return messages; if (!msg) return messages;
const content = msg.content as Array<Record<string, unknown>> | undefined; const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : undefined;
if (!Array.isArray(content)) return messages; if (!content) return messages;
for (const block of content) { for (const block of content) {
switch (block.type) { switch (block.type) {
@ -146,7 +156,7 @@ function adaptAssistantMessage(
id: `${uuid}-text-${messages.length}`, id: `${uuid}-text-${messages.length}`,
type: 'text', type: 'text',
parentId, parentId,
content: { text: block.text as string } satisfies TextContent, content: { text: str(block.text) } satisfies TextContent,
timestamp, timestamp,
}); });
break; break;
@ -155,7 +165,7 @@ function adaptAssistantMessage(
id: `${uuid}-think-${messages.length}`, id: `${uuid}-think-${messages.length}`,
type: 'thinking', type: 'thinking',
parentId, parentId,
content: { text: (block.thinking ?? block.text) as string } satisfies ThinkingContent, content: { text: str(block.thinking ?? block.text) } satisfies ThinkingContent,
timestamp, timestamp,
}); });
break; break;
@ -165,8 +175,8 @@ function adaptAssistantMessage(
type: 'tool_call', type: 'tool_call',
parentId, parentId,
content: { content: {
toolUseId: block.id as string, toolUseId: str(block.id),
name: block.name as string, name: str(block.name),
input: block.input, input: block.input,
} satisfies ToolCallContent, } satisfies ToolCallContent,
timestamp, timestamp,
@ -185,11 +195,11 @@ function adaptUserMessage(
parentId?: string, parentId?: string,
): AgentMessage[] { ): AgentMessage[] {
const messages: AgentMessage[] = []; const messages: AgentMessage[] = [];
const msg = raw.message as Record<string, unknown> | undefined; const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record<string, unknown> : undefined;
if (!msg) return messages; if (!msg) return messages;
const content = msg.content as Array<Record<string, unknown>> | undefined; const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : undefined;
if (!Array.isArray(content)) return messages; if (!content) return messages;
for (const block of content) { for (const block of content) {
if (block.type === 'tool_result') { if (block.type === 'tool_result') {
@ -198,7 +208,7 @@ function adaptUserMessage(
type: 'tool_result', type: 'tool_result',
parentId, parentId,
content: { content: {
toolUseId: block.tool_use_id as string, toolUseId: str(block.tool_use_id),
output: block.content ?? raw.tool_use_result, output: block.content ?? raw.tool_use_result,
} satisfies ToolResultContent, } satisfies ToolResultContent,
timestamp, timestamp,
@ -214,20 +224,20 @@ function adaptResultMessage(
uuid: string, uuid: string,
timestamp: number, timestamp: number,
): AgentMessage[] { ): AgentMessage[] {
const usage = raw.usage as Record<string, number> | undefined; const usage = typeof raw.usage === 'object' && raw.usage !== null ? raw.usage as Record<string, unknown> : undefined;
return [{ return [{
id: uuid, id: uuid,
type: 'cost', type: 'cost',
content: { content: {
totalCostUsd: (raw.total_cost_usd as number) ?? 0, totalCostUsd: num(raw.total_cost_usd),
durationMs: (raw.duration_ms as number) ?? 0, durationMs: num(raw.duration_ms),
inputTokens: usage?.input_tokens ?? 0, inputTokens: num(usage?.input_tokens),
outputTokens: usage?.output_tokens ?? 0, outputTokens: num(usage?.output_tokens),
numTurns: (raw.num_turns as number) ?? 0, numTurns: num(raw.num_turns),
isError: (raw.is_error as boolean) ?? false, isError: raw.is_error === true,
result: raw.result as string | undefined, result: typeof raw.result === 'string' ? raw.result : undefined,
errors: raw.errors as string[] | undefined, errors: Array.isArray(raw.errors) ? raw.errors.filter((e): e is string => typeof e === 'string') : undefined,
} satisfies CostContent, } satisfies CostContent,
timestamp, timestamp,
}]; }];

View file

@ -278,10 +278,11 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
input_tokens: session.inputTokens, input_tokens: session.inputTokens,
output_tokens: session.outputTokens, output_tokens: session.outputTokens,
last_prompt: session.prompt, last_prompt: session.prompt,
updated_at: Date.now(), updated_at: Math.floor(Date.now() / 1000),
}); });
// Save messages // Save messages (use seconds to match session.rs convention)
const nowSecs = Math.floor(Date.now() / 1000);
const records: AgentMessageRecord[] = session.messages.map((m, i) => ({ const records: AgentMessageRecord[] = session.messages.map((m, i) => ({
id: i, id: i,
session_id: sessionId, session_id: sessionId,
@ -290,7 +291,7 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
message_type: m.type, message_type: m.type,
content: JSON.stringify(m.content), content: JSON.stringify(m.content),
parent_id: m.parentId ?? null, parent_id: m.parentId ?? null,
created_at: Date.now(), created_at: nowSecs,
})); }));
if (records.length > 0) { if (records.length > 0) {