From 0dcab9bfb32bcec6d9ae821ca2129d395700f821 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 06:49:02 +0200 Subject: [PATCH 1/3] security: minimize public health diagnostics --- Sources/CodexAccountSwitcher/main.swift | 15 ++++-- .../SetupStatusTests.swift | 8 ++- docs/SECURITY.md | 2 +- gateway/codex_phone_gateway.py | 24 +++++++-- gateway/test_codex_phone_gateway.py | 54 ++++++++++++++++++- ops/agents/escalations/security-scan.md | 3 +- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index 116b3c58..62e5254d 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -2569,8 +2569,12 @@ enum SwitchError: LocalizedError { } enum CodePilotGatewayHealthProbe { - static func request() -> URLRequest { - URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!) + static func request(token: String?) -> URLRequest { + var request = URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!) + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return request } static func requirement(from data: Data?) -> CodePilotSetupRequirement { @@ -2725,7 +2729,12 @@ struct CodePilotSetupStatus { } private static func gatewayHealthStatus() -> CodePilotGatewayHealthStatus { - CodePilotGatewayHealthProbe.status(from: synchronousData(for: CodePilotGatewayHealthProbe.request())) + let tokenPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/phone-gateway-token") + let token = try? String(contentsOf: tokenPath, encoding: .utf8) + return CodePilotGatewayHealthProbe.status( + from: synchronousData(for: CodePilotGatewayHealthProbe.request(token: token)) + ) } private static func synchronousData(for request: URLRequest) -> Data? { diff --git a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift index f92353c7..6fdbf924 100644 --- a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift +++ b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift @@ -79,7 +79,7 @@ final class SetupStatusTests: XCTestCase { } func testGatewayHealthProbeUsesPublicHealthWithoutBearerToken() { - let request = CodePilotGatewayHealthProbe.request() + let request = CodePilotGatewayHealthProbe.request(token: nil) XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) let runningPayload = #"{"gateway":{"running":true}}"#.data(using: .utf8) @@ -90,6 +90,12 @@ final class SetupStatusTests: XCTestCase { XCTAssertEqual(CodePilotGatewayHealthProbe.requirement(from: Data("not json".utf8)), .gatewayStopped) } + func testGatewayHealthProbeUsesTokenForPrivateDiagnostics() { + let request = CodePilotGatewayHealthProbe.request(token: " private-token\n") + + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer private-token") + } + func testGatewayHealthProbeReportsNotificationReadiness() { let configuredPayload = #"{"gateway":{"running":true},"notifications":{"configured":true}}"#.data(using: .utf8) XCTAssertEqual( diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 023e3609..8436666d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,7 +25,7 @@ Recommended: The iOS client refuses non-HTTPS gateway URLs except for `localhost`, `::1`, and syntactically valid `127.x.x.x` loopback addresses. DNS names that merely begin with `127.` are not loopback exceptions and still require HTTPS. The client also rejects gateway URLs containing user information, query strings, or fragments, and credential-bearing requests do not follow cross-origin redirects. -The gateway health response is intentionally public-safe, but it is still operational metadata. Treat the gateway URL and token as private. +The unauthenticated gateway health response contains only liveness and version data. Account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. Do not place infrastructure or connector management tokens in `phone-gateway.env`. The gateway service deliberately does not load `SUPABASE_ACCESS_TOKEN`, and gateway-launched Codex processes scrub gateway-only credentials from their environment. Configure sensitive connectors in a separately trusted local Codex session instead of exposing their bearer tokens to remotely initiated turns. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index c8f3babb..7964b9f9 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2303,6 +2303,14 @@ def app_server_auth_status(self) -> dict: } def public_health(self) -> dict: + return { + "gateway": { + "running": True, + "version": read_marker(DEFAULT_SWITCHER_HOME / "gateway-version", "dev"), + }, + } + + def diagnostic_health(self) -> dict: remote_desktop_status = {"available": False} remote_desktop = self.remote_desktop_gateway if remote_desktop is not None: @@ -4888,9 +4896,7 @@ def end_headers(self): super().end_headers() def authenticate(self) -> bool: - expected = self.state().token - header = self.headers.get("authorization", "") - if secrets.compare_digest(header, f"Bearer {expected}"): + if self.is_authenticated(): return True json_error( self, @@ -4901,6 +4907,11 @@ def authenticate(self) -> bool: ) return False + def is_authenticated(self) -> bool: + expected = self.state().token + header = self.headers.get("authorization", "") + return secrets.compare_digest(header, f"Bearer {expected}") + def do_GET(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path.rstrip("/") or "/" @@ -4931,7 +4942,12 @@ def do_GET(self): return if path == "/health" or path == "/api/health": - json_response(self, 200, self.state().public_health()) + payload = ( + self.state().diagnostic_health() + if self.is_authenticated() + else self.state().public_health() + ) + json_response(self, 200, payload) return if not self.authenticate(): diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index f6bc3e8c..43bb49ce 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1254,7 +1254,7 @@ def process_factory(arguments, **_kwargs): finally: gateway.JOBS.pop(job_id, None) - def test_public_health_exposes_safe_gateway_status(self): + def test_public_health_exposes_only_non_identifying_liveness(self): with tempfile.TemporaryDirectory() as tmp: original_switcher_home = gateway.DEFAULT_SWITCHER_HOME switcher_home = Path(tmp) / "switcher" @@ -1271,12 +1271,30 @@ def test_public_health_exposes_safe_gateway_status(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertTrue(health["gateway"]["running"]) + self.assertEqual(set(health), {"gateway"}) + self.assertNotIn("secret-token", json.dumps(health)) + + def test_diagnostic_health_retains_authenticated_setup_status(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + (switcher_home / "active-account.txt").write_text("main", encoding="utf-8") + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + + health = state.diagnostic_health() + finally: + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + self.assertEqual(health["accounts"]["active"], "main") self.assertIn("auth", health["accounts"]) self.assertIn("notifications", health) self.assertIn("remoteDesktop", health) self.assertIn("localWeb", health) - self.assertNotIn("secret-token", json.dumps(health)) def test_health_endpoint_is_available_without_bearer_token(self): with tempfile.TemporaryDirectory() as tmp: @@ -1309,8 +1327,40 @@ def test_health_endpoint_is_available_without_bearer_token(self): self.assertEqual(status, 200) self.assertTrue(payload["gateway"]["running"]) + self.assertEqual(set(payload), {"gateway"}) self.assertNotIn("secret-token", json.dumps(payload)) + def test_health_endpoint_returns_diagnostics_with_bearer_token(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + server = None + thread = None + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + url = f"http://127.0.0.1:{server.server_address[1]}/api/health" + request = urllib.request.Request(url, headers={"Authorization": "Bearer secret-token"}) + with urllib.request.urlopen(request, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + finally: + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + + self.assertIn("accounts", payload) + self.assertIn("notifications", payload) + def test_error_payload_has_stable_code_message_and_recovery(self): payload = gateway.error_payload( "local_web_invalid_target", diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 32488095..f1889f53 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -6,11 +6,10 @@ Detailed security findings are intentionally not stored in the public repository Before public launch, maintainer coordination is still required for: -- Reviewing and resolving the open GitHub secret-scanning alert through the private security channel, including revocation or rotation where applicable. - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling repository dependency alerts and reviewing the initial results. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From 6600e0a6b9bc3e2248c0142748d763fd693944a2 Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 07:58:44 +0200 Subject: [PATCH 2/3] security: reduce gateway fingerprinting --- gateway/codex_phone_gateway.py | 5 ++++- gateway/test_codex_phone_gateway.py | 2 ++ ops/agents/escalations/security-scan.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 7964b9f9..b3d9b630 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -4881,7 +4881,10 @@ def run_turn_exec( class Handler(BaseHTTPRequestHandler): - server_version = "CodexPhoneGateway/0.1" + server_version = "CodePilotGateway" + + def version_string(self) -> str: + return self.server_version def state(self) -> GatewayState: return self.server.state diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 43bb49ce..78f70c12 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1316,6 +1316,7 @@ def test_health_endpoint_is_available_without_bearer_token(self): url = f"http://127.0.0.1:{server.server_address[1]}/api/health" with urllib.request.urlopen(url, timeout=2) as response: status = response.status + server_header = response.headers.get("Server") payload = json.loads(response.read().decode("utf-8")) finally: if server is not None: @@ -1326,6 +1327,7 @@ def test_health_endpoint_is_available_without_bearer_token(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertEqual(status, 200) + self.assertEqual(server_header, "CodePilotGateway") self.assertTrue(payload["gateway"]["running"]) self.assertEqual(set(payload), {"gateway"}) self.assertNotIn("secret-token", json.dumps(payload)) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index f1889f53..7a8f6199 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -9,7 +9,7 @@ Before public launch, maintainer coordination is still required for: - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial results. +- Enabling dependency alerts and security updates, code scanning, plus non-provider secret patterns and validity checks, then reviewing the initial results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. From 49b52c9c04e1718f492cc8dcf7bb50b888ff458c Mon Sep 17 00:00:00 2001 From: CodePilot Date: Sun, 19 Jul 2026 08:56:57 +0200 Subject: [PATCH 3/3] security: harden public gateway and scanning --- .github/dependabot.yml | 19 +++++++++ .github/workflows/codeql.yml | 54 +++++++++++++++++++++++++ docs/SECURITY.md | 2 +- gateway/codex_phone_gateway.py | 7 ++-- gateway/test_codex_phone_gateway.py | 30 +++++++++++++- ops/agents/escalations/security-scan.md | 4 +- 6 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..41edd987 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: bundler + directory: /ios/CodexPhone + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..9811eea1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "23 4 * * 2" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + os: ubuntu-latest + build-mode: none + - language: python + os: ubuntu-latest + build-mode: none + - language: ruby + os: ubuntu-latest + build-mode: none + - language: swift + os: macos-latest + build-mode: autobuild + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Autobuild + if: matrix.build-mode == 'autobuild' + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + + - name: Analyze + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8436666d..c9b1d55e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,7 +25,7 @@ Recommended: The iOS client refuses non-HTTPS gateway URLs except for `localhost`, `::1`, and syntactically valid `127.x.x.x` loopback addresses. DNS names that merely begin with `127.` are not loopback exceptions and still require HTTPS. The client also rejects gateway URLs containing user information, query strings, or fragments, and credential-bearing requests do not follow cross-origin redirects. -The unauthenticated gateway health response contains only liveness and version data. Account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. +The unauthenticated gateway health response contains only a liveness flag. Version, account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. Do not place infrastructure or connector management tokens in `phone-gateway.env`. The gateway service deliberately does not load `SUPABASE_ACCESS_TOKEN`, and gateway-launched Codex processes scrub gateway-only credentials from their environment. Configure sensitive connectors in a separately trusted local Codex session instead of exposing their bearer tokens to remotely initiated turns. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index b3d9b630..fb25daa3 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2306,7 +2306,6 @@ def public_health(self) -> dict: return { "gateway": { "running": True, - "version": read_marker(DEFAULT_SWITCHER_HOME / "gateway-version", "dev"), }, } @@ -4931,15 +4930,15 @@ def do_GET(self): self, 404, "local_web_unavailable", - str(exc), + "Local web session not found or expired", "Open the localhost link again from CodePilot; local web sessions expire after a short time.", ) - except Exception as exc: + except Exception: json_error( self, 502, "local_web_unavailable", - str(exc), + "The selected local web server is unavailable", "Make sure the local web server is running on the Mac, then reopen the link.", ) return diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index 78f70c12..52c2802d 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -6,9 +6,9 @@ import subprocess import tempfile import threading +import urllib.error import urllib.request import unittest -import urllib.request from unittest import mock from pathlib import Path @@ -1272,6 +1272,7 @@ def test_public_health_exposes_only_non_identifying_liveness(self): self.assertTrue(health["gateway"]["running"]) self.assertEqual(set(health), {"gateway"}) + self.assertEqual(set(health["gateway"]), {"running"}) self.assertNotIn("secret-token", json.dumps(health)) def test_diagnostic_health_retains_authenticated_setup_status(self): @@ -1330,8 +1331,35 @@ def test_health_endpoint_is_available_without_bearer_token(self): self.assertEqual(server_header, "CodePilotGateway") self.assertTrue(payload["gateway"]["running"]) self.assertEqual(set(payload), {"gateway"}) + self.assertEqual(set(payload["gateway"]), {"running"}) self.assertNotIn("secret-token", json.dumps(payload)) + def test_local_web_endpoint_hides_backend_failure_details(self): + state = GatewayState( + Path("/tmp/codex"), + "secret-token", + Path("/missing-codex"), + False, + local_web_fetcher=mock.Mock(side_effect=RuntimeError("private backend detail")), + ) + session = state.start_local_web_session("http://localhost:3000/") + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_address[1]}{session['path']}" + with self.assertRaises(urllib.error.HTTPError) as raised: + urllib.request.urlopen(url, timeout=2) + payload = json.loads(raised.exception.read().decode("utf-8")) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.assertEqual(raised.exception.code, 502) + self.assertEqual(payload["error"]["message"], "The selected local web server is unavailable") + self.assertNotIn("private backend detail", json.dumps(payload)) + def test_health_endpoint_returns_diagnostics_with_bearer_token(self): with tempfile.TemporaryDirectory() as tmp: original_switcher_home = gateway.DEFAULT_SWITCHER_HOME diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 7a8f6199..2e8a37a7 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -9,7 +9,9 @@ Before public launch, maintainer coordination is still required for: - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling dependency alerts and security updates, code scanning, plus non-provider secret patterns and validity checks, then reviewing the initial results. +- Protecting the default branch with required reviews and passing checks. +- Enabling private vulnerability reporting before inviting public security reports. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial Dependabot, CodeQL, and secret-scanning results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests.