fix(ownership): remove silent cross-tenant credential fallback - #2099
Conversation
…M wrappers (#2069, #2070) WASM tool credential resolution silently fell back to looking up secrets under the hardcoded "default" scope when the calling user had no credential configured, leaking the instance owner's API keys to other users without error or audit trail. - Remove "default" fallback in resolve_host_credentials(); return Err(ToolError::NotAuthorized) with actionable message instead of silently skipping missing credentials - Fix resolve_websocket_identify_message() to accept owner_scope_id parameter instead of hardcoding "default" - Document legacy broadcast metadata fallback with removal tracking - Document setup.rs boot-time owner_id lookups as intentional instance-level resource ownership - Add regression tests proving cross-tenant credentials do not leak Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Removes silent cross-tenant credential fallback in WASM tool execution to enforce per-user secret scoping, and fixes WASM channel websocket identify secret resolution to use the channel owner scope rather than a hardcoded "default".
Changes:
- Make
resolve_host_credentials()returnResultand error withToolError::NotAuthorizedwhen required secrets can’t be resolved (no"default"fallback). - Pass
owner_scope_idinto websocket identify resolution and use it forget_decrypted()instead of"default". - Add ownership-model documentation for channel boot-time secret lookups and document the legacy broadcast-metadata fallback.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/tools/wasm/wrapper.rs | Removes "default" credential fallback; returns NotAuthorized for missing credentials and adds regression tests. |
| src/channels/wasm/wrapper.rs | Uses owner_scope_id when resolving websocket identify secrets; documents legacy broadcast-metadata fallback and adds regression test. |
| src/channels/wasm/setup.rs | Documents why boot-time channel secrets are resolved under config.owner_id (instance-level ownership). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Code Review
This pull request implements a stricter ownership model for WASM channels and tools, ensuring that credentials are resolved within the correct scope and removing insecure cross-tenant fallbacks. Key changes include updating websocket identification to use the channel's owner scope and modifying tool credential resolution to return a NotAuthorized error instead of silently skipping missing secrets or falling back to a global 'default' user. Review feedback suggests refining the logic in resolve_host_credentials to ensure that UrlPath credentials (which do not require the secrets store) do not trigger authorization errors and that error messages distinguish between missing and expired secrets.
…ude UrlPath from store check Address PR review feedback: - Filter out UrlPath credentials in the no-store check so tools with only UrlPath mappings don't incorrectly get NotAuthorized - Match SecretError::Expired separately to produce "has expired" message instead of misleading "not found" - Add tests for both: UrlPath-only no-store (Ok), expired credential (specific error message) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…orized Address @serrrfirat review: Database, DecryptionFailed, KeychainError, and other backend errors were incorrectly mapped to "not found". Now: - NotFound → ToolError::NotAuthorized ("not found, configure via secrets set") - Expired → ToolError::NotAuthorized ("has expired, refresh or re-set") - All others → ToolError::ExecutionFailed (preserves real cause) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/tools/wasm/wrapper.rs:97
ResolvedHostCredentialnow derivesDebug, but it containssecret_value(the raw decrypted credential). Any accidental{:?}logging/panic of this struct (or a parent struct containing it) would leak secrets into logs/test output. Consider removingDebugor implementing a custom redactingDebugimpl that omits/obfuscatessecret_value(and ideally headers/query params too, since they may embed the same secret).
/// Pre-resolved credential for host-based injection.
///
/// Built before each WASM execution by decrypting secrets from the store.
/// Applied per-request by matching the URL host against `host_patterns`.
/// WASM tools never see the raw secret values.
#[derive(Debug)]
struct ResolvedHostCredential {
/// Host patterns this credential applies to (e.g., "www.googleapis.com").
host_patterns: Vec<String>,
/// Headers to add to matching requests (e.g., "Authorization: Bearer ...").
headers: HashMap<String, String>,
/// Query parameters to add to matching requests.
query_params: HashMap<String, String>,
/// Raw secret value for redaction in error messages.
secret_value: String,
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ibility - Map SecretError::AccessDenied to ToolError::NotAuthorized (not ExecutionFailed) since it's an authorization failure - Update legacy fallback comments to reference #2100 (the tracking issue) instead of #2069 - Revert resolve_websocket_identify_message to private — test uses super:: import instead of pub(crate) path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…variant - Replace #[derive(Debug)] on ResolvedHostCredential with custom impl that redacts secret_value and auth headers to prevent latent leakage - Add comment documenting that all declared non-UrlPath credentials are required — tool execution fails on first missing credential rather than running with partial auth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Code reviewFound 4 issues:
Positive findings:
🤖 Generated with Claude Code |
…i#2099) * fix(ownership): remove silent cross-tenant credential fallback in WASM wrappers (nearai#2069, nearai#2070) WASM tool credential resolution silently fell back to looking up secrets under the hardcoded "default" scope when the calling user had no credential configured, leaking the instance owner's API keys to other users without error or audit trail. - Remove "default" fallback in resolve_host_credentials(); return Err(ToolError::NotAuthorized) with actionable message instead of silently skipping missing credentials - Fix resolve_websocket_identify_message() to accept owner_scope_id parameter instead of hardcoding "default" - Document legacy broadcast metadata fallback with removal tracking - Document setup.rs boot-time owner_id lookups as intentional instance-level resource ownership - Add regression tests proving cross-tenant credentials do not leak Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(review): differentiate expired vs missing credential errors, exclude UrlPath from store check Address PR review feedback: - Filter out UrlPath credentials in the no-store check so tools with only UrlPath mappings don't incorrectly get NotAuthorized - Match SecretError::Expired separately to produce "has expired" message instead of misleading "not found" - Add tests for both: UrlPath-only no-store (Ok), expired credential (specific error message) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(review): map backend SecretErrors to ExecutionFailed, not NotAuthorized Address @serrrfirat review: Database, DecryptionFailed, KeychainError, and other backend errors were incorrectly mapped to "not found". Now: - NotFound → ToolError::NotAuthorized ("not found, configure via secrets set") - Expired → ToolError::NotAuthorized ("has expired, refresh or re-set") - All others → ToolError::ExecutionFailed (preserves real cause) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(review): AccessDenied → NotAuthorized, fix issue refs, reduce visibility - Map SecretError::AccessDenied to ToolError::NotAuthorized (not ExecutionFailed) since it's an authorization failure - Update legacy fallback comments to reference nearai#2100 (the tracking issue) instead of nearai#2069 - Revert resolve_websocket_identify_message to private — test uses super:: import instead of pub(crate) path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(review): redact secrets in Debug impl, document all-or-nothing invariant - Replace #[derive(Debug)] on ResolvedHostCredential with custom impl that redacts secret_value and auth headers to prevent latent leakage - Add comment documenting that all declared non-UrlPath credentials are required — tool execution fails on first missing credential rather than running with partial auth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Summary
resolve_host_credentials()no longer falls back to"default"scope when a user's credential is missing. ReturnsErr(ToolError::NotAuthorized)with an actionable message instead.resolve_websocket_identify_message()to use the channel'sowner_scope_idinstead of hardcoded"default".Note: The broadcast metadata legacy fallback in
load_broadcast_metadata()is documented but NOT removed — tracked in #2100.Addresses #2069 (broadcast metadata fallback deferred — see #2100)
Addresses #2070 (broadcast metadata fallback deferred — see #2100)
Test plan
test_resolve_host_credentials_no_cross_tenant_fallback— credential under "default" does NOT leak to another usertest_resolve_host_credentials_missing_secret_returns_error— missing cred returnsNotAuthorizedwith credential + user nametest_resolve_host_credentials_no_store_with_credentials_errors— no store + required creds returns errortest_resolve_host_credentials_skips_urlpath_credentials— UrlPath creds skipped without errortest_resolve_websocket_identify_message_uses_owner_scope— websocket uses owner scope, not "default"cargo clippy --all --all-features— zero warningsgrep get_decrypted("default" src/— zero hits in production code🤖 Generated with Claude Code