mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
Sanitize credentials from Git remote metadata (#40713)
## Why Git remote URLs can embed usernames, passwords, or tokens. Codex carries these URLs through turn metadata and persisted thread metadata, so credentials must be removed before a remote enters those paths. ## What changed - Add `SanitizedGitUrl`, which parses Git URL and SCP-style remotes, strips authentication data, and preserves the conventional `git` SSH user. - Use sanitized remotes when collecting Git metadata, enriching model requests, discovering cloud environments, updating thread metadata, and reading legacy rollouts. Reject or omit malformed remotes instead of retaining their raw contents. - Keep API, schema, and TypeScript representations as strings while enforcing sanitization in Rust. ## Testing - Cover URL schemes, SCP and IPv6 forms, remote helpers, encoded paths, malformed values, and legacy deserialization. - Verify credentials do not appear in model requests, API responses, SQLite, or rollout files. GitOrigin-RevId: 6435efc4c45bfbfad4723ce7a0457cb00175f25c
This commit is contained in:
1 parent
399be2d6b5
commit
1bc02aea50
31 files changed
+1073
-68
No files matched your search
@@ -37,6 +37,7 @@ use codex_app_server_protocol::UserInput;
|
||||
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_features::Feature;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::SanitizedGitUrl;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::GitInfo as CoreGitInfo;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
@@ -1886,7 +1887,10 @@ async fn thread_list_includes_git_info() -> Result<()> {
|
||||
let git_info = CoreGitInfo {
|
||||
commit_hash: Some(GitSha::new("abc123")),
|
||||
branch: Some("main".to_string()),
|
||||
repository_url: Some("https://example.com/repo.git".to_string()),
|
||||
repository_url: Some(
|
||||
SanitizedGitUrl::try_from("https://example.com/repo.git")
|
||||
.expect("repository URL should be valid"),
|
||||
),
|
||||
};
|
||||
let conversation_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
@@ -1926,6 +1930,65 @@ async fn thread_list_includes_git_info() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legacy rollout credentials must be sanitized before thread/list returns Git metadata.
|
||||
#[tokio::test]
|
||||
async fn thread_list_sanitizes_git_info_from_existing_rollouts() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_minimal_config(codex_home.path())?;
|
||||
|
||||
let git_info = CoreGitInfo {
|
||||
commit_hash: Some(GitSha::new("abc123")),
|
||||
branch: Some("main".to_string()),
|
||||
repository_url: Some(
|
||||
SanitizedGitUrl::try_from("https://example.com/repo.git")
|
||||
.expect("repository URL should be valid"),
|
||||
),
|
||||
};
|
||||
let conversation_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-02-01T09-00-00",
|
||||
"2025-02-01T09:00:00Z",
|
||||
"Git info preview",
|
||||
Some("mock_provider"),
|
||||
Some(git_info),
|
||||
)?;
|
||||
let path = rollout_path(codex_home.path(), "2025-02-01T09-00-00", &conversation_id);
|
||||
let rollout = fs::read_to_string(&path)?;
|
||||
fs::write(
|
||||
path,
|
||||
rollout.replace(
|
||||
"https://example.com/repo.git",
|
||||
"https://alice:synthetic-rollout-secret@example.com/repo.git",
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
let ThreadListResponse { data, .. } = list_threads(
|
||||
&mut mcp,
|
||||
/*cursor*/ None,
|
||||
Some(10),
|
||||
Some(vec!["mock_provider".to_string()]),
|
||||
/*source_kinds*/ None,
|
||||
/*archived*/ None,
|
||||
)
|
||||
.await?;
|
||||
let thread = data
|
||||
.iter()
|
||||
.find(|thread| thread.id == conversation_id)
|
||||
.expect("expected thread for created rollout");
|
||||
|
||||
assert_eq!(
|
||||
thread.git_info,
|
||||
Some(ApiGitInfo {
|
||||
sha: Some("abc123".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
origin_url: Some("https://example.com/repo.git".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_default_sorts_by_created_at() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_app_server_protocol::GitInfo;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
|
||||
@@ -30,6 +31,7 @@ use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_features::Feature;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::SanitizedGitUrl;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::GitInfo as RolloutGitInfo;
|
||||
use codex_rollout::state_db::reconcile_rollout;
|
||||
@@ -651,6 +653,90 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Client-provided Git credentials must not reach API responses, SQLite, or rollout files.
|
||||
#[tokio::test]
|
||||
async fn thread_metadata_update_sanitizes_git_origin_before_persisting() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
||||
let state_db = init_state_db(codex_home.path()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
history_mode: Some(ThreadHistoryMode::Legacy),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let update_id = mcp
|
||||
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
||||
thread_id: thread.id.clone(),
|
||||
project_id: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: None,
|
||||
origin_url: Some(Some(
|
||||
"https://alice:synthetic-git-secret@example.invalid/org/repo.git".to_string(),
|
||||
)),
|
||||
}),
|
||||
})
|
||||
.await?;
|
||||
let update_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(update_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadMetadataUpdateResponse { thread: updated } =
|
||||
to_response::<ThreadMetadataUpdateResponse>(update_resp)?;
|
||||
let expected_git_info = Some(GitInfo {
|
||||
sha: None,
|
||||
branch: None,
|
||||
origin_url: Some("https://example.invalid/org/repo.git".to_string()),
|
||||
});
|
||||
assert_eq!(updated.git_info, expected_git_info);
|
||||
|
||||
let persisted = state_db
|
||||
.get_thread(ThreadId::from_string(&thread.id)?)
|
||||
.await?
|
||||
.expect("updated thread must be persisted");
|
||||
assert_eq!(
|
||||
persisted.git_origin_url.as_deref(),
|
||||
Some("https://example.invalid/org/repo.git")
|
||||
);
|
||||
let rollout = fs::read_to_string(persisted.rollout_path)?;
|
||||
assert!(!rollout.contains("synthetic-git-secret"));
|
||||
assert!(rollout.contains("https://example.invalid/org/repo.git"));
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_read_request(ThreadReadParams {
|
||||
thread_id: thread.id,
|
||||
include_turns: false,
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread: read, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
assert_eq!(read.git_info, expected_git_info);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -1004,7 +1090,10 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
|
||||
Some(RolloutGitInfo {
|
||||
commit_hash: Some(GitSha::new("abc123")),
|
||||
branch: Some("feature/sidebar-pr".to_string()),
|
||||
repository_url: Some("git@example.com:openai/codex.git".to_string()),
|
||||
repository_url: Some(
|
||||
SanitizedGitUrl::try_from("git@example.com:openai/codex.git")
|
||||
.expect("repository URL should be valid"),
|
||||
),
|
||||
}),
|
||||
)?;
|
||||
let _state_db = init_state_db(codex_home.path()).await?;
|
||||
|
||||
Reference in new issue
Block a user