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:
parent
044f891c3a
commit
3f1638c98b
10 changed files with 97 additions and 57 deletions
14
ctx
14
ctx
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue