fix: address final review (path traversal, sync races, MCP limits)

Findings from a Codex (gpt-6.1-sol, xhigh) review of v0.2.4..HEAD, each
verified in the code before fixing:

- high: ticker symbols reached cache file paths unchecked, so a symbol
  like `../../REPORT` read (and deleted, if unparseable) .json files
  outside the cache, reachable through MCP get_quotes. resolve_symbol now
  accepts only ticker characters, and cache keys are encoded as single
  safe path components.
- high: sync decided before downloading and replaced the database
  without rechecking; an import that landed meanwhile was erased. It now
  rechecks the local state before installing, and idx-mcp runs
  ownership_sync exclusively with ownership reads.
- replaces_sqlite_sha256 never downgrades history: the client requires
  the rebuild to keep at least the local release count, and the
  publisher lists a replaced build only then.
- cache writes are atomic (temp file + rename), so concurrent readers
  never see, and delete, a partial entry.
- idx-mcp caps symbols (20, deduplicated) and limits, and runs at most 4
  tool calls at once.
- each Yahoo client gets its own cookie jar, paired with its crumb.
- ZIP entries (XLSX, KSEI archives) are read with a 256 MiB bound;
  non-ASCII archive dates and huge percentages are parse errors instead
  of panics or overflow.
- agent-smoke --keep deletes every copied agent config, not only auth
  files.

CLI goldens unchanged; the MCP tool golden changes only to state the
new caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 21:58:37 +00:00
commit 60634d3aef
16 changed files with 310 additions and 59 deletions

View file

@ -202,6 +202,14 @@ pub fn sync_snapshot(
let install_result = (|| -> Result<(), IdxError> {
validate_snapshot_db(&temp_path, &manifest.snapshot)?;
// The decision was made before the download; an import or edit that
// landed since must not be overwritten without --force.
if !force && !same_local_state(local_state.as_ref(), inspect_local_db(db_path)?.as_ref()) {
return Err(IdxError::DatabaseError(
"the local ownership database changed during sync; rerun `idx ownership sync`"
.to_string(),
));
}
install_snapshot_file(&temp_path, db_path)
})();
@ -362,6 +370,21 @@ fn inspect_local_db(db_path: &Path) -> Result<Option<LocalSnapshotState>, IdxErr
}))
}
fn same_local_state(
before: Option<&LocalSnapshotState>,
after: Option<&LocalSnapshotState>,
) -> bool {
match (before, after) {
(None, None) => true,
(Some(before), Some(after)) => {
before.file_sha256.is_some()
&& before.file_sha256 == after.file_sha256
&& before.release_count == after.release_count
}
_ => false,
}
}
/// Moves committed WAL frames into the database file, so its hash covers local
/// edits too. Returns false when changes remain in the WAL.
fn checkpoint_wal(db_path: &Path) -> bool {
@ -446,10 +469,12 @@ fn build_sync_decision(
};
}
if snapshot
.replaces_sqlite_sha256
.iter()
.any(|sha256| file_sha256.is_some_and(|local| sha256.eq_ignore_ascii_case(local)))
// A rebuild never silently drops history the local database has.
if snapshot.release_count >= local.release_count
&& snapshot
.replaces_sqlite_sha256
.iter()
.any(|sha256| file_sha256.is_some_and(|local| sha256.eq_ignore_ascii_case(local)))
{
return SyncDecision {
action: OwnershipSyncAction::Updated,
@ -909,6 +934,20 @@ mod tests {
assert!(!decision.should_download);
}
#[test]
fn build_sync_decision_never_drops_history_through_a_rebuild() {
// A forced same-date rebuild with fewer releases lists the old build;
// a client holding it keeps its history instead of downgrading.
let earlier = "e".repeat(64);
let mut snapshot = same_date_snapshot(vec![earlier.clone()]);
snapshot.release_count = 1;
let mut local = same_release_local(&snapshot, &earlier);
local.release_count = 3;
let decision = build_sync_decision(Some(&local), &snapshot, false);
assert!(!decision.should_download, "{}", decision.reason);
}
#[test]
fn build_sync_decision_keeps_a_locally_modified_database() {
// Same release metadata but a file matching no published build (local