Skip to content

fix(agent): block thread_id-based context pollution across users - #760

Merged
zmanian merged 3 commits into
nearai:stagingfrom
pikaxinge:fix/thread-id-context-pollution
Mar 11, 2026
Merged

zmanian merged 3 commits into
nearai:stagingfrom
pikaxinge:fix/thread-id-context-pollution

Conversation

@pikaxinge

Copy link
Copy Markdown
Contributor

Summary

This PR fixes a high-severity context pollution risk where a client could submit a forged thread_id UUID and cause cross-user conversation hydration/persistence against a foreign conversation ID.

Why this is severe

  • A forged/known UUID could inject foreign history into model context.
  • New attacker content could be persisted into the victim conversation stream.
  • This is a direct context integrity boundary break in multi-user flows.

What changed

  • Added ownership validation before DB hydration in thread restoration flow.
  • Added a guarded persistence helper to ensure writes only target:
    • conversations already owned by the current user, or
    • brand-new conversations safely created for the current user.
  • Reused this guard across user/assistant/tool-call persistence paths.

Regression test

  • Added tests/e2e_thread_id_isolation.rs to verify a forged thread_id:
    • does not hydrate foreign marker content into prompt context,
    • does not persist attacker/assistant messages into the victim conversation.

Validation

Ran:

  • OPENSSL_DIR=/usr OPENSSL_LIB_DIR=/usr/lib/x86_64-linux-gnu OPENSSL_INCLUDE_DIR=/usr/include cargo test --test e2e_thread_id_isolation --no-default-features --features libsql -- --nocapture

Result:

  • 1 passed; 0 failed

Copilot AI review requested due to automatic review settings March 9, 2026 16:23
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: new First-time contributor labels Mar 9, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This PR addresses a critical security vulnerability where a malicious actor could manipulate thread_ids to access or alter other users' conversation data. The changes introduce strict ownership checks at both the conversation hydration and message persistence layers, ensuring that each user's conversation history remains isolated and secure.

Highlights

  • Security Fix: Context Pollution: Implemented robust ownership validation for thread_ids to prevent unauthorized access and modification of conversation history across different users.
  • New Persistence Guard: Introduced ensure_writable_conversation to guarantee that messages are only persisted to conversations owned by the current user or newly created for them.
  • Comprehensive Test Coverage: Added an end-to-end regression test (e2e_thread_id_isolation.rs) to specifically verify that forged thread_ids cannot lead to cross-user data hydration or persistence.
Changelog
  • src/agent/thread_ops.rs
    • Added ownership validation for thread_ids during conversation history hydration.
    • Introduced ensure_writable_conversation helper to guard message persistence operations.
    • Refactored message persistence functions to utilize the new ensure_writable_conversation guard.
  • tests/e2e_thread_id_isolation.rs
    • Added a new end-to-end test to verify cross-user thread_id isolation for both hydration and persistence.
Activity
  • The author, pikaxinge, created this pull request to address a high-severity security vulnerability.
  • The pull request includes new code to enforce thread ownership and prevent context pollution.
  • A new end-to-end test has been added to validate the fix.
  • The author has validated the fix by running the new test successfully.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses a high-severity context pollution risk by implementing robust ownership validation during thread hydration and persistence. The introduction of the ensure_writable_conversation helper centralizes critical security logic, making the codebase more secure and maintainable. The new end-to-end test e2e_thread_id_isolation.rs provides excellent coverage, verifying that forged thread_ids do not lead to cross-user data leakage or persistence. The refactoring to use the new helper function across various persistence paths is well-executed, enhancing the overall integrity of conversation management.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses a high-severity cross-user context pollution risk by preventing hydration/persistence against a forged thread_id that targets another user’s conversation UUID.

Changes:

  • Added ownership validation before hydrating conversation history from the DB during thread restoration.
  • Introduced a guarded persistence helper to ensure DB writes only occur for user-owned or newly-created conversations.
  • Added an E2E regression test covering forged thread_id isolation across users.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/agent/thread_ops.rs Adds ownership checks for hydration and a shared guard for safe persistence to prevent cross-user thread ID abuse.
tests/e2e_thread_id_isolation.rs Adds an end-to-end test ensuring forged thread IDs do not hydrate or persist foreign conversation data.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/thread_ops.rs Outdated
Comment on lines +80 to +84
if !owned {
tracing::warn!(
user = %message.user_id,
thread_id = %thread_uuid,
"Rejected hydration for unowned thread id"

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe_hydrate_thread now rejects hydration whenever conversation_belongs_to_user returns false. That includes the common case where the conversation row does not exist yet (brand-new thread UUID from the frontend), which defeats the function’s stated purpose of creating an in-memory thread with the exact external UUID and will cause resolve_thread to mint a new internal UUID (breaking persistence into the intended conversation). Consider distinguishing "missing conversation" from "foreign conversation" (e.g., if belongs=false then check get_conversation_metadata(thread_uuid): allow/initialize empty thread on None, but reject on Some(_)).

Copilot uses AI. Check for mistakes.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review: thread_id-based context pollution fix

This PR addresses a real and high-severity vulnerability -- a client submitting a forged thread UUID could hydrate a foreign user's conversation history into the LLM prompt and persist attacker-controlled messages into the victim's conversation. The overall approach (ownership checks at hydration + persistence) is correct and necessary. However, there are several issues that should be addressed before merging.

Critical: TOCTOU race in ensure_writable_conversation

The new ensure_writable_conversation method performs three sequential non-atomic database calls:

  1. conversation_belongs_to_user(thread_id, user_id) -- returns false
  2. get_conversation_metadata(thread_id) -- returns None (not yet created)
  3. ensure_conversation(thread_id, "gateway", user_id, None) -- INSERT ... ON CONFLICT DO UPDATE

Between steps 2 and 3, a concurrent request from a different user could create the same conversation UUID (e.g., a race between two users both trying to claim the same uncreated UUID). The ON CONFLICT (id) DO UPDATE SET last_activity = ... upsert in both postgres and libsql backends does NOT check user_id on the conflict path -- it just bumps last_activity. This means:

  • User A's request checks: no conversation exists -> proceeds to step 3
  • User B's request creates the conversation with their user_id between steps 2 and 3
  • User A's ensure_conversation hits the ON CONFLICT path, bumps last_activity but does NOT change user_id
  • The post-check at step 4 (conversation_belongs_to_user again) correctly catches this

So the post-insert re-check (the third conversation_belongs_to_user call) does mitigate the race for the attacker, but the ensure_conversation call still unnecessarily bumps last_activity on the victim's conversation. This is a minor side effect but worth noting.

More importantly, this three-query dance should be a single transaction or a conditional INSERT. Consider:

INSERT INTO conversations (id, channel, user_id, ...)
VALUES ($1, $2, $3, ...)
ON CONFLICT (id) DO NOTHING

Then check conversation_belongs_to_user once after. This avoids the metadata lookup entirely and is both simpler and safer. The DO NOTHING variant means a concurrent insert from another user simply fails silently, and the post-check catches it.

Issue: ensure_conversation upsert semantics are unsafe for multi-user

The underlying ensure_conversation uses ON CONFLICT (id) DO UPDATE SET last_activity = NOW(). In a multi-user context, this is dangerous -- if an attacker guesses a valid conversation UUID, calling ensure_conversation will silently bump the victim's last_activity timestamp even though the ownership check later rejects the write. This leaks timing information (the victim sees their conversation's last_activity change) and is a minor integrity violation. The ensure_conversation contract should probably be tightened: either the upsert should verify user_id matches on conflict, or ensure_writable_conversation should use a conditional INSERT instead.

Issue: Hydration guard uses early return silently

In maybe_hydrate_thread, when ownership validation fails, the method does return; silently (after logging). The caller (process_user_input or wherever this is invoked) has no way to know that hydration was rejected vs. simply no history existed. For a security boundary, failing loudly is preferable. Consider:

  • Returning a Result or bool so the caller can decide whether to proceed or reject the message entirely
  • Currently, a forged thread_id that fails hydration will still be processed as a new conversation (the agent will respond). The attacker doesn't get the victim's history, but the response still goes through. Is that the intended behavior? If so, document it.

Issue: "gateway" channel hardcoded in ensure_writable_conversation

The method hardcodes "gateway" as the channel when creating a new conversation:

store.ensure_conversation(thread_id, "gateway", user_id, None).await

This means the guard only works correctly for the gateway channel. If other channels (Telegram, webhook, REPL) ever flow through this code path, the channel metadata will be wrong. Consider passing the channel from the incoming message context.

Nit: Redundant ownership check pattern

ensure_writable_conversation calls conversation_belongs_to_user up to 3 times in the worst case (initial check, implicit in ensure, post-ensure re-check). The logic would be cleaner as:

// Try conditional insert first (no-op if already exists)
store.ensure_conversation_if_new(thread_id, user_id, ...).await?;  // INSERT ... ON CONFLICT DO NOTHING
// Single ownership check
store.conversation_belongs_to_user(thread_id, user_id).await

Test coverage

The regression test is well-structured and covers the key scenarios (no hydration of foreign history, no persistence into foreign conversation). It runs under libsql, which is good. A few suggestions:

  • Consider also testing the case where the attacker uses a UUID that does not exist at all (not just one owned by another user). The current ensure_writable_conversation should handle this correctly by creating a new conversation owned by the attacker, but it's worth having an explicit assertion.
  • The test only covers the libsql backend (#[cfg(feature = "libsql")]). The postgres backend has identical SQL semantics here, but the ownership check is a security boundary -- consider adding a note or tracking issue for postgres integration test coverage.

Summary

The fix correctly identifies and addresses the core vulnerability. The hydration guard and persistence guards are placed at the right points. However, the ensure_writable_conversation implementation has TOCTOU concerns and the underlying ensure_conversation upsert semantics are arguably too permissive for a multi-user environment. I'd recommend:

  1. Switch ensure_conversation to ON CONFLICT DO NOTHING (or add a user_id-aware variant) to avoid touching foreign conversations
  2. Simplify ensure_writable_conversation to: conditional-insert + single ownership check
  3. Consider whether the hydration rejection should propagate to the caller (reject the message vs. silently start a new conversation)
  4. Avoid hardcoding "gateway" channel

@henrypark133
henrypark133 changed the base branch from main to staging March 10, 2026 02:19
@github-actions github-actions Bot added scope: channel/web Web gateway channel scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend contributor: regular 2-5 merged PRs and removed contributor: new First-time contributor labels Mar 10, 2026
Copilot AI review requested due to automatic review settings March 10, 2026 06:46
@pikaxinge
pikaxinge force-pushed the fix/thread-id-context-pollution branch from daead7c to 12a7a8f Compare March 10, 2026 06:46
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Mar 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/channels/web/server.rs:1227

  • When ensure_conversation returns Ok(false) (UUID conflict with a different (channel,user_id)), the handler still calls update_conversation_metadata_field(thread_id, ...). Since update_conversation_metadata_field updates by id only (no ownership filter), this can mutate metadata on a foreign conversation row despite detecting the conflict. Gate the metadata update on Ok(true) (and ideally return an error / retry with a new UUID on Ok(false)), so no writes occur against an unowned conversation ID.
            Ok(true) => {}
            Ok(false) => tracing::warn!(
                user = %state.user_id,
                thread_id = %thread_id,
                "Skipped persisting new thread due to ownership/channel conflict"
            ),
            Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
        }
        let metadata_val = serde_json::json!("thread");
        if let Err(e) = store
            .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val)
            .await

src/channels/web/handlers/chat.rs:544

  • ensure_conversation returning Ok(false) indicates the thread_id already exists for a different (channel,user_id), but the code still unconditionally calls update_conversation_metadata_field(thread_id, ...). Because that update is keyed only by id, this can write metadata into a foreign conversation row even after detecting the conflict. Only update metadata when ensure_conversation returns Ok(true) (and consider surfacing an error/retry path on Ok(false)), to keep all persistence behind the ownership/channel guard.
            Ok(true) => {}
            Ok(false) => tracing::warn!(
                user = %state.user_id,
                thread_id = %thread_id,
                "Skipped persisting new thread due to ownership/channel conflict"
            ),
            Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
        }
        let metadata_val = serde_json::json!("thread");
        if let Err(e) = store
            .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val)
            .await

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pikaxinge
pikaxinge requested review from Copilot and zmanian March 10, 2026 11:29
@pikaxinge
pikaxinge force-pushed the fix/thread-id-context-pollution branch from 3055c62 to 18f4eeb Compare March 10, 2026 15:48
@github-actions github-actions Bot added scope: channel Channel infrastructure scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: tool Tool infrastructure scope: tool/builtin Built-in tools labels Mar 10, 2026
@github-actions github-actions Bot added scope: setup Onboarding / setup scope: sandbox Docker sandbox scope: ci CI/CD workflows scope: docs Documentation risk: high Safety, secrets, auth, or critical infrastructure and removed risk: medium Business logic, config, or moderate-risk modules labels Mar 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

.github/workflows/staging-ci.yml:122

  • The actions/create-github-app-token@v2 step is no longer guarded. On forks / environments where GH_RELEASES_MANAGER_APP_ID and GH_RELEASES_MANAGER_APP_PRIVATE_KEY are unset, this step will fail before you can fall back to github.token. Restore the conditional guard (or add continue-on-error: true) so the workflow remains functional without those secrets.
      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
          private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}

.github/workflows/staging-ci.yml:236

  • Same issue as earlier in this workflow: this create-github-app-token step is unconditionally executed, so missing GH_RELEASES_MANAGER_APP_* secrets will fail the job and prevent the intended fallback to github.token. Add a guard or continue-on-error to keep staging CI usable in environments without those secrets.
      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
          private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +809 to +814
let tool_defs = ctx.tools.tool_definitions().await;

let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execute_lightweight_with_tools exposes all tool definitions to the LLM (ctx.tools.tool_definitions()), but execute_routine_tool later blocks UnlessAutoApproved/Always tools. This makes the model likely to select tools that will be rejected, wasting iterations and producing noisy error tool-results. Consider filtering the advertised tool definitions up-front to only the subset allowed in lightweight routines (e.g., tools whose requires_approval is Never).

Copilot uses AI. Check for mistakes.
Comment thread src/main.rs
Comment on lines 222 to +239

// ── Tunnel setup ───────────────────────────────────────────────────

let (config, active_tunnel) = start_tunnel(config).await;
let (config, active_tunnel) = ironclaw::tunnel::start_managed_tunnel(config).await;

// ── Orchestrator / container job manager ────────────────────────────

// Proactive Docker detection
let docker_status = if config.sandbox.enabled {
let detection = ironclaw::sandbox::check_docker().await;
match detection.status {
ironclaw::sandbox::DockerStatus::Available => {
tracing::info!("Docker is available");
}
ironclaw::sandbox::DockerStatus::NotInstalled => {
tracing::warn!(
"Docker is not installed -- sandbox disabled for this session. {}",
detection.platform.install_hint()
);
}
ironclaw::sandbox::DockerStatus::NotRunning => {
tracing::warn!(
"Docker is installed but not running -- sandbox disabled for this session. {}",
detection.platform.start_hint()
);
}
ironclaw::sandbox::DockerStatus::Disabled => {}
}
detection.status
} else {
ironclaw::sandbox::DockerStatus::Disabled
};

let job_event_tx: Option<
tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>,
> = if config.sandbox.enabled && docker_status.is_ok() {
let (tx, _) = tokio::sync::broadcast::channel(256);
Some(tx)
} else {
None
};
let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::<
uuid::Uuid,
std::collections::VecDeque<ironclaw::orchestrator::api::PendingPrompt>,
>::new()));

let container_job_manager: Option<Arc<ContainerJobManager>> =
if config.sandbox.enabled && docker_status.is_ok() {
let token_store = TokenStore::new();
let job_config = ContainerJobConfig {
image: config.sandbox.image.clone(),
memory_limit_mb: config.sandbox.memory_limit_mb,
cpu_shares: config.sandbox.cpu_shares,
orchestrator_port: 50051,
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
claude_code_oauth_token: ironclaw::config::ClaudeCodeConfig::extract_oauth_token(),
claude_code_model: config.claude_code.model.clone(),
claude_code_max_turns: config.claude_code.max_turns,
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
};
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));

// Start the orchestrator internal API in the background
let orchestrator_state = OrchestratorState {
llm: components.llm.clone(),
job_manager: Arc::clone(&jm),
token_store,
job_event_tx: job_event_tx.clone(),
prompt_queue: Arc::clone(&prompt_queue),
store: components.db.clone(),
secrets_store: components.secrets_store.clone(),
user_id: "default".to_string(),
};

tokio::spawn(async move {
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
tracing::error!("Orchestrator API failed: {}", e);
}
});

if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
config.claude_code.model,
config.claude_code.max_turns
);
}
Some(jm)
} else {
None
};
let orch = ironclaw::orchestrator::setup_orchestrator(
&config,
&components.llm,
components.db.as_ref(),
components.secrets_store.as_ref(),
)
.await;
let container_job_manager = orch.container_job_manager;
let job_event_tx = orch.job_event_tx;
let prompt_queue = orch.prompt_queue;
let docker_status = orch.docker_status;

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description focuses on thread_id ownership validation, but this diff also includes broad refactors and behavior changes (orchestrator/tunnel setup extraction, onboarding quick mode, MCP factory, tracing level changes, tool approval changes, etc.). This scope mismatch makes it hard to reason about risk for a security fix; consider splitting the unrelated refactors into separate PRs so the thread_id isolation change can be reviewed and landed independently.

Copilot uses AI. Check for mistakes.
Comment thread src/tools/mcp/factory.rs
Comment on lines +44 to +51
Ok(McpClient::new_with_transport(
&server_name,
transport as Arc<dyn McpTransport>,
None,
secrets,
user_id,
Some(server),
))

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spawn_stdio() returns Arc<StdioMcpTransport>, but this code attempts to cast it with as Arc<dyn McpTransport>. Arc<T> → Arc<dyn Trait> should use trait-object coercion (e.g., bind to Arc<dyn McpTransport> / Arc::from(transport)), not an as cast; as written this is likely a compile error and blocks MCP stdio support.

Copilot uses AI. Check for mistakes.
Comment thread src/tools/mcp/factory.rs
Comment on lines +65 to +72
Ok(McpClient::new_with_transport(
&server_name,
Arc::new(transport) as Arc<dyn McpTransport>,
None,
secrets,
user_id,
Some(server),
))

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Unix transport path uses Arc::new(transport) as Arc<dyn McpTransport>. Similar to the stdio case, prefer trait-object coercion to Arc<dyn McpTransport> rather than an as cast; otherwise this is likely to fail to compile or behave unexpectedly across Rust versions.

Copilot uses AI. Check for mistakes.
@pikaxinge
pikaxinge force-pushed the fix/thread-id-context-pollution branch from 18f4eeb to fc46fff Compare March 10, 2026 16:16
@github-actions github-actions Bot added risk: medium Business logic, config, or moderate-risk modules and removed risk: high Safety, secrets, auth, or critical infrastructure labels Mar 10, 2026
zmanian
zmanian previously approved these changes Mar 10, 2026

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: thread_id-based context pollution fix

All four issues from my previous review have been addressed in the new commits:

  1. TOCTOU race / ensure_conversation upsert semantics -- Fixed correctly. The ON CONFLICT DO UPDATE now has a WHERE clause checking user_id and channel match on both postgres and libsql backends. This is the right approach -- a single atomic SQL statement that returns affected-rows > 0 only when the row is newly inserted or belongs to the same user. No more multi-query dance. The return type change from Result<()> to Result<bool> cleanly communicates ownership rejection.

  2. Hydration guard returns silently -- Fixed. maybe_hydrate_thread now returns Option<String>, and the caller in agent_loop.rs propagates the rejection as an error response. Gateway/test channels get hard rejection before any LLM call. Good.

  3. Hardcoded "gateway" channel -- Fixed. All persist methods now accept channel from message.channel.

  4. Redundant ownership checks -- Fixed. ensure_writable_conversation is now a thin wrapper around the single ensure_conversation call + boolean check.

New design observations

The requires_preexisting_uuid_thread helper distinguishing "gateway"/"test" channels from others is a reasonable policy choice. Gateway clients should never be creating conversations by providing unknown UUIDs (the server issues them), so rejecting unknown UUIDs is correct. Non-gateway channels that happen to pass UUID-shaped thread IDs get the softer behavior (skip hydration, proceed as new conversation).

The error path in maybe_hydrate_thread where conversation_belongs_to_user returns an Err (DB failure) defaults to rejection for gateway channels and permissive for others. This is the right fail-closed posture for the security-critical path.

Test coverage

Good regression tests:

  • test_ensure_conversation_foreign_conflict_does_not_touch_last_activity verifies the SQL-level guard (attacker cannot bump last_activity)
  • E2E test covers both foreign-existing and nonexistent thread UUIDs
  • Verifies no LLM calls leak foreign context and no messages are persisted to victim conversation
  • Verifies agent still works for legitimate requests after rejection

Minor notes for future

  • The E2E test and the test_ensure_conversation_foreign_conflict_does_not_touch_last_activity test are both libsql-only. Worth tracking a postgres integration test for the same SQL guard, since the two SQL dialects are slightly different (EXCLUDED vs excluded). Not a blocker.
  • The Copilot comments about unrelated scope (orchestrator refactor, MCP factory, etc.) appear to be noise from a stale diff on a different base -- the current diff only touches thread_id isolation files. Ignore those.

Security fix looks correct and complete. Approving.

Copilot AI review requested due to automatic review settings March 11, 2026 03:31
@pikaxinge
pikaxinge force-pushed the fix/thread-id-context-pollution branch from fc46fff to 16979dc Compare March 11, 2026 03:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/channels/web/server.rs:1436

  • ensure_conversation can now return Ok(false) when the UUID already exists under a different (channel, user_id). In that case this handler still calls update_conversation_metadata_field(thread_id, ...), which updates by id only and would mutate the existing foreign row. Consider only updating metadata when ensure_conversation returns Ok(true) (and skipping/returning an error on Ok(false)).
        match store
            .ensure_conversation(thread_id, "gateway", &state.user_id, None)
            .await
        {
            Ok(true) => {}
            Ok(false) => tracing::warn!(
                user = %state.user_id,
                thread_id = %thread_id,
                "Skipped persisting new thread due to ownership/channel conflict"
            ),
            Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
        }
        let metadata_val = serde_json::json!("thread");
        if let Err(e) = store
            .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val)
            .await

src/channels/web/handlers/chat.rs:568

  • ensure_conversation can now return Ok(false) when the UUID already exists under a different (channel, user_id). In that case this handler still calls update_conversation_metadata_field(thread_id, ...), which updates by id only and would mutate the existing foreign row. Consider only updating metadata when ensure_conversation returns Ok(true) (and skipping/returning an error on Ok(false)).
        match store
            .ensure_conversation(thread_id, "gateway", &state.user_id, None)
            .await
        {
            Ok(true) => {}
            Ok(false) => tracing::warn!(
                user = %state.user_id,
                thread_id = %thread_id,
                "Skipped persisting new thread due to ownership/channel conflict"
            ),
            Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
        }
        let metadata_val = serde_json::json!("thread");
        if let Err(e) = store
            .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val)
            .await

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/thread_ops.rs
let thread_uuid = match Uuid::parse_str(external_thread_id) {
Ok(id) => id,
Err(_) => return,
Err(_) => return None,

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For gateway/test channels you appear to require server-issued UUID thread IDs, but invalid/non-UUID external_thread_id currently returns None (no rejection). That allows clients to bypass the preexisting-UUID requirement by sending a non-UUID thread_id, causing resolve_thread to create a new thread keyed by that string. Consider returning the same safe rejection message when Uuid::parse_str fails and requires_preexisting_uuid_thread(&message.channel) is true.

Suggested change
Err(_) => return None,
Err(_) => {
// For gateway-style channels that require server-issued UUIDs,
// reject non-UUID / malformed thread IDs instead of silently
// letting a new thread be created for them.
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
}

Copilot uses AI. Check for mistakes.
Comment thread src/agent/thread_ops.rs
Comment on lines 71 to +76
if let Some(store) = self.store() {
// Never hydrate history from a conversation UUID that isn't owned
// by the current authenticated user.
let owned = match store
.conversation_belongs_to_user(thread_uuid, &message.user_id)
.await

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hydration authorization checks only conversation_belongs_to_user(id, user_id) (no channel), but writes are later guarded by ensure_conversation(id, channel, user_id, ...) which enforces both user and channel. This mismatch can lead to a thread being hydrated from a different channel for the same user and then all persistence being rejected due to channel conflict. Consider validating (id, channel, user_id) consistently during hydration (or explicitly documenting/handling the cross-channel case).

Copilot uses AI. Check for mistakes.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: thread_id-based context pollution fix (post-rebase)

Reviewing the 3-commit series after the previous review was dismissed due to new commits.

Previous issues -- all addressed

  1. TOCTOU race / ensure_conversation upsert -- Fixed. Both postgres (src/history/store.rs) and libsql (src/db/libsql/conversations.rs) now use ON CONFLICT (id) DO UPDATE ... WHERE conversations.user_id = EXCLUDED.user_id AND conversations.channel = EXCLUDED.channel. This is a single atomic statement that returns affected-rows > 0 only when the row is new or belongs to the same (user, channel). Clean fix.

  2. Silent hydration rejection -- Fixed. maybe_hydrate_thread now returns Option<String>, caller in agent_loop.rs propagates it as an error response. Gateway/test channels get hard rejection before any LLM call reaches the provider.

  3. Hardcoded "gateway" channel -- Fixed. All persist_* methods now accept channel from message.channel and pass it through to ensure_writable_conversation.

  4. Redundant ownership checks -- Fixed. ensure_writable_conversation is now a single ensure_conversation call + boolean check. No more multi-query dance.

New code analysis

requires_preexisting_uuid_thread -- Reasonable policy. Gateway/test channels require server-issued UUIDs; unknown UUIDs are rejected. Other channels get softer behavior (skip hydration, proceed as new conversation). The matches!(channel, "gateway" | "test") is tight and appropriate.

SQL correctness -- The WHERE clause on the ON CONFLICT DO UPDATE is correct for both dialects. PostgreSQL uses EXCLUDED (uppercase by convention but case-insensitive), libsql uses excluded (lowercase) -- both are valid for their respective engines. The affected > 0 check works because: INSERT succeeds (affected=1, new row), or UPDATE matches WHERE (affected=1, owned row refreshed), or UPDATE WHERE fails (affected=0, foreign row untouched).

Test coverage -- test_ensure_conversation_foreign_conflict_does_not_touch_last_activity directly verifies the SQL guard. E2E test covers both foreign-existing and nonexistent thread UUIDs. Both verify no LLM context leakage and no foreign message persistence. The follow-up-after-rejection test is a nice liveness check.

Remaining minor items (non-blocking)

  1. update_conversation_metadata_field not guarded on Ok(false) -- In both src/channels/web/handlers/chat.rs and src/channels/web/server.rs, when ensure_conversation returns Ok(false) (UUID conflict), the code logs a warning but still falls through to call update_conversation_metadata_field(thread_id, "thread_type", ...). That method updates by id alone with no ownership filter, so it could mutate metadata on a foreign conversation row. In practice this is low-risk in chat_new_thread_handler (the UUID is freshly generated by Uuid::new_v4() so collision is astronomically unlikely), but for defense-in-depth the metadata update should be gated on Ok(true). Worth a follow-up.

  2. Non-UUID thread_id bypass for gateway channels -- Copilot flagged this: if a gateway client sends a non-UUID thread_id, Uuid::parse_str fails and maybe_hydrate_thread returns None (no rejection). The message proceeds to resolve_thread which creates a new thread keyed by that string. This bypasses the requires_preexisting_uuid_thread guard. Since the gateway should only ever issue UUID thread IDs, consider rejecting non-UUID thread IDs for gateway channels too. Low-risk since the attacker just gets their own new thread, but it violates the stated invariant. Worth a follow-up.

  3. libsql-only test coverage -- Both the unit test and E2E test are #[cfg(feature = "libsql")]. The postgres SQL uses EXCLUDED (standard) while libsql uses excluded -- worth tracking a postgres integration test for the same guard. Not blocking since the SQL semantics are equivalent.

Verdict

The core security fix is correct, complete, and well-tested. The atomic upsert approach is the right design. The remaining items are defense-in-depth improvements that don't affect the security boundary this PR establishes. Approving.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: thread_id-based context pollution fix (post-rebase)

Reviewing the 3-commit series after the previous review was dismissed due to new commits.

Previous issues -- all addressed

  1. TOCTOU race / ensure_conversation upsert -- Fixed. Both postgres and libsql now use ON CONFLICT (id) DO UPDATE ... WHERE conversations.user_id = EXCLUDED.user_id AND conversations.channel = EXCLUDED.channel. Single atomic statement, returns affected-rows > 0 only when the row is new or belongs to the same (user, channel).

  2. Silent hydration rejection -- Fixed. maybe_hydrate_thread now returns Option, caller in agent_loop.rs propagates it as an error response. Gateway/test channels get hard rejection before any LLM call.

  3. Hardcoded gateway channel -- Fixed. All persist_* methods now accept channel from message.channel.

  4. Redundant ownership checks -- Fixed. ensure_writable_conversation is now a single ensure_conversation call + boolean check.

New code analysis

requires_preexisting_uuid_thread -- Reasonable policy. Gateway/test channels require server-issued UUIDs; unknown UUIDs are rejected. Other channels get softer behavior.

SQL correctness -- The WHERE clause on ON CONFLICT DO UPDATE is correct for both dialects. affected > 0 works because: INSERT succeeds (1, new row), or UPDATE matches WHERE (1, owned row refreshed), or UPDATE WHERE fails (0, foreign row untouched).

Test coverage -- test_ensure_conversation_foreign_conflict_does_not_touch_last_activity verifies the SQL guard. E2E tests cover foreign-existing and nonexistent thread UUIDs. Both verify no LLM context leakage and no foreign persistence.

Remaining minor items (non-blocking)

  1. update_conversation_metadata_field not guarded on Ok(false) -- In both chat.rs handlers, when ensure_conversation returns Ok(false), code still calls update_conversation_metadata_field which updates by id with no ownership filter. Low-risk since UUID is freshly minted by new_v4(), but for defense-in-depth the metadata update should be gated on Ok(true). Worth a follow-up.

  2. Non-UUID thread_id bypass for gateway channels -- If a gateway client sends a non-UUID thread_id, Uuid::parse_str fails and maybe_hydrate_thread returns None (no rejection). This bypasses the requires_preexisting_uuid_thread guard. Low-risk since attacker just gets their own thread, but violates the stated invariant. Worth a follow-up.

  3. libsql-only test coverage -- Both tests are cfg(feature = libsql). Worth tracking a postgres integration test. Not blocking since SQL semantics are equivalent.

Verdict

The core security fix is correct, complete, and well-tested. The atomic upsert approach is the right design. Remaining items are defense-in-depth improvements that don't affect the security boundary. Approving.

@pikaxinge

pikaxinge commented Mar 11, 2026 •

Copy link
Copy Markdown
Contributor Author

@zmanian Hi, just a quick update: I rebased this branch onto the latest staging (head: 16979dc) and all checks are green again. Since this required a force-push, the previous approval was dismissed. When you have a moment, could you please re-approve? Appreciate your time.

@zmanian
zmanian merged commit 2094d6e into nearai:staging Mar 11, 2026
17 of 18 checks passed
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Mar 12, 2026
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
…rai#760)

* fix(agent): prevent forged thread UUID context/write contamination

* fix(agent): close thread_id race and reject forged UUID hydration

* fix(ci): satisfy clippy and fmt checks after rebase
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
…rai#760)

* fix(agent): prevent forged thread UUID context/write contamination

* fix(agent): close thread_id race and reject forged UUID hydration

* fix(ci): satisfy clippy and fmt checks after rebase
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: regular 2-5 merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: ci CI/CD workflows scope: config Configuration scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: docs Documentation scope: extensions Extension management scope: llm LLM integration scope: orchestrator Container orchestrator scope: sandbox Docker sandbox scope: secrets Secrets management scope: setup Onboarding / setup scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: tool/wasm WASM tool sandbox scope: tool Tool infrastructure scope: worker Container worker size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants