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

14
ctx
View file

@ -278,7 +278,11 @@ def cmd_history(args):
print("Usage: ctx history <project> [limit]")
sys.exit(1)
project = args[0]
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()
rows = db.execute(
"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)
db = get_db()
# Search project contexts
# Search project contexts (FTS5 MATCH can fail on malformed query syntax)
try:
results_ctx = db.execute(
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
(query,),
).fetchall()
except sqlite3.OperationalError:
print(f"Invalid search query: '{query}' (FTS5 syntax error)")
db.close()
sys.exit(1)
# Search shared contexts
try:
results_shared = db.execute(
"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)
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
const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('CLAUDE')) {
if (!key.startsWith('CLAUDE') && !key.startsWith('ANTHROPIC_')) {
cleanEnv[key] = value;
}
}

View file

@ -127,7 +127,7 @@ impl CtxDb {
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
).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);
Ok(())
@ -149,7 +149,7 @@ impl CtxDb {
}
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 mut stmt = conn
@ -173,7 +173,7 @@ impl CtxDb {
}
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 mut stmt = conn
@ -197,7 +197,7 @@ impl CtxDb {
}
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 mut stmt = conn
@ -220,7 +220,7 @@ impl CtxDb {
}
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 mut stmt = conn
@ -236,7 +236,14 @@ impl CtxDb {
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<_>, _>>()
.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
let toml_path = entry.path().join("profile.toml");
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, "subscription_type"),
@ -606,7 +609,10 @@ pub fn run() {
.handle()
.path()
.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"))
.parent()
.unwrap()

View file

@ -226,13 +226,12 @@ impl RemoteManager {
// 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) {
machine.status = "disconnected".to_string();
machine.connection = None;
}
}
}
let _ = app_handle.emit("remote-machine-disconnected", &serde_json::json!({
"machineId": mid,
}));

View file

@ -284,7 +284,8 @@ impl SessionDb {
pub fn save_layout(&self, layout: &LayoutState) -> Result<(), String> {
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(
"UPDATE layout_state SET preset = ?1, pane_ids = ?2 WHERE id = 1",
params![layout.preset, pane_ids_json],

View file

@ -72,8 +72,10 @@ impl FileWatcherManager {
)
.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
.watch(file_path.parent().unwrap_or(&file_path), RecursiveMode::NonRecursive)
.watch(watch_dir, RecursiveMode::NonRecursive)
.map_err(|e| format!("Failed to watch path: {e}"))?;
let mut watchers = self.watchers.lock().unwrap();

View file

@ -56,7 +56,9 @@ export async function onSidecarMessage(
callback: (msg: SidecarMessage) => void,
): Promise<UnlistenFn> {
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;
}
/** 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.
* When SDK changes wire format, only this function needs updating.
*/
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 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) {
case 'system':
@ -99,17 +109,17 @@ function adaptSystemMessage(
uuid: string,
timestamp: number,
): AgentMessage[] {
const subtype = raw.subtype as string;
const subtype = str(raw.subtype);
if (subtype === 'init') {
return [{
id: uuid,
type: 'init',
content: {
sessionId: raw.session_id as string,
model: raw.model as string,
cwd: raw.cwd as string,
tools: (raw.tools as string[]) ?? [],
sessionId: str(raw.session_id),
model: str(raw.model),
cwd: str(raw.cwd),
tools: Array.isArray(raw.tools) ? raw.tools.filter((t): t is string => typeof t === 'string') : [],
} satisfies InitContent,
timestamp,
}];
@ -120,7 +130,7 @@ function adaptSystemMessage(
type: 'status',
content: {
subtype,
message: raw.status as string | undefined,
message: typeof raw.status === 'string' ? raw.status : undefined,
} satisfies StatusContent,
timestamp,
}];
@ -133,11 +143,11 @@ function adaptAssistantMessage(
parentId?: string,
): 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;
const content = msg.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) return messages;
const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : undefined;
if (!content) return messages;
for (const block of content) {
switch (block.type) {
@ -146,7 +156,7 @@ function adaptAssistantMessage(
id: `${uuid}-text-${messages.length}`,
type: 'text',
parentId,
content: { text: block.text as string } satisfies TextContent,
content: { text: str(block.text) } satisfies TextContent,
timestamp,
});
break;
@ -155,7 +165,7 @@ function adaptAssistantMessage(
id: `${uuid}-think-${messages.length}`,
type: 'thinking',
parentId,
content: { text: (block.thinking ?? block.text) as string } satisfies ThinkingContent,
content: { text: str(block.thinking ?? block.text) } satisfies ThinkingContent,
timestamp,
});
break;
@ -165,8 +175,8 @@ function adaptAssistantMessage(
type: 'tool_call',
parentId,
content: {
toolUseId: block.id as string,
name: block.name as string,
toolUseId: str(block.id),
name: str(block.name),
input: block.input,
} satisfies ToolCallContent,
timestamp,
@ -185,11 +195,11 @@ function adaptUserMessage(
parentId?: string,
): 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;
const content = msg.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) return messages;
const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : undefined;
if (!content) return messages;
for (const block of content) {
if (block.type === 'tool_result') {
@ -198,7 +208,7 @@ function adaptUserMessage(
type: 'tool_result',
parentId,
content: {
toolUseId: block.tool_use_id as string,
toolUseId: str(block.tool_use_id),
output: block.content ?? raw.tool_use_result,
} satisfies ToolResultContent,
timestamp,
@ -214,20 +224,20 @@ function adaptResultMessage(
uuid: string,
timestamp: number,
): 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 [{
id: uuid,
type: 'cost',
content: {
totalCostUsd: (raw.total_cost_usd as number) ?? 0,
durationMs: (raw.duration_ms as number) ?? 0,
inputTokens: usage?.input_tokens ?? 0,
outputTokens: usage?.output_tokens ?? 0,
numTurns: (raw.num_turns as number) ?? 0,
isError: (raw.is_error as boolean) ?? false,
result: raw.result as string | undefined,
errors: raw.errors as string[] | undefined,
totalCostUsd: num(raw.total_cost_usd),
durationMs: num(raw.duration_ms),
inputTokens: num(usage?.input_tokens),
outputTokens: num(usage?.output_tokens),
numTurns: num(raw.num_turns),
isError: raw.is_error === true,
result: typeof raw.result === 'string' ? raw.result : undefined,
errors: Array.isArray(raw.errors) ? raw.errors.filter((e): e is string => typeof e === 'string') : undefined,
} satisfies CostContent,
timestamp,
}];

View file

@ -278,10 +278,11 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
input_tokens: session.inputTokens,
output_tokens: session.outputTokens,
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) => ({
id: i,
session_id: sessionId,
@ -290,7 +291,7 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
message_type: m.type,
content: JSON.stringify(m.content),
parent_id: m.parentId ?? null,
created_at: Date.now(),
created_at: nowSecs,
}));
if (records.length > 0) {