diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c6625da..6f209034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,19 +6,22 @@ on: branches: - main +permissions: + contents: read + jobs: test: name: Test and Audit runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Privacy audit run: scripts/privacy-audit.sh - - name: Public presence audit - run: scripts/public-presence-audit.sh + - name: Privacy audit regression tests + run: scripts/tests/privacy-audit-test.sh - name: SwiftPM tests run: swift test @@ -27,6 +30,27 @@ jobs: working-directory: gateway run: python3 -m unittest test_codex_phone_gateway.py test_remote_desktop_gateway.py + - name: Gateway token process-argument guard + run: scripts/tests/gateway-token-argv-test.sh + + - name: LaunchAgent owner-only umask guard + run: scripts/tests/launch-agent-umask-test.sh + + - name: Cloudflare setup security tests + run: scripts/test-cloudflare-setup.sh + + - name: Background-agent opt-in guard + run: scripts/tests/background-agent-opt-in-test.sh + + - name: Continuous-agent public-write guard + run: scripts/tests/codepilot-agent-public-write-guard-test.sh + + - name: Desktop sync security tests + run: python3 -m unittest scripts/tests/test_desktop_sync_security.py + + - name: Desktop sync transport security tests + run: scripts/tests/push-desktop-sync-security-test.sh + - name: Fastlane syntax run: ruby -c ios/CodexPhone/fastlane/Fastfile diff --git a/.gitignore b/.gitignore index 344822f6..ce82137e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,34 @@ AGENTS.md docs/superpowers/ .env *.env +.env.* +!.env.example +.envrc +.dev.vars +*.credentials +*.secrets +auth.json +credentials.json +service-account*.json +*.p8 +*.key +*.jks +*.keystore +*.sqlite +*.sqlite3 +*.db +.netrc +.npmrc +.pypirc phone-gateway-token phone-gateway.env +AuthKey_*.p8 +*.pem +*.p12 +*.mobileprovision +*.provisionprofile +.cloudflared/ +.wrangler/ *.log logs/ uploads/ diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index c7c169ea..b5af815f 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -109,10 +109,11 @@ enum CodePilotHostServicesManager { } private static func unloadAndRemoveLegacyLaunchAgents() { - [ - "com.tony.codex-phone-gateway", - "com.tony.codex-phone-cloudflared" - ].forEach { label in + let legacyLabels = ProcessInfo.processInfo.environment["CODEPILOT_LEGACY_LAUNCHD_LABELS"]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } ?? [] + legacyLabels.forEach { label in let plist = launchAgents.appendingPathComponent("\(label).plist") runLaunchctl(["unload", plist.path]) try? fileManager.removeItem(at: plist) @@ -173,8 +174,12 @@ enum CodePilotHostServicesManager { let scheduler = bundledResource("scripts/codepilot-agent-scheduler.sh") ?? repoRoot.appendingPathComponent("scripts/codepilot-agent-scheduler.sh") guard fileManager.fileExists(atPath: scheduler.path) else { return } - let threadID = (try? String(contentsOf: appDir.appendingPathComponent("agents/thread-id"), encoding: .utf8)) - ?? "019e2d20-3695-7423-be21-968f544d2b20" + let threadIDFile = appDir.appendingPathComponent("agents/thread-id") + guard let threadID = try? String(contentsOf: threadIDFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + !threadID.isEmpty else { + return + } let codex = preferredCodexURL()?.path ?? executable(named: "codex")?.path ?? "codex" try writeLaunchAgent( label: "io.codepilot.agents.scheduler", @@ -190,7 +195,7 @@ enum CodePilotHostServicesManager { "CODEPILOT_AGENT_MODEL": "gpt-5.6-sol", "CODEPILOT_AGENT_REASONING_EFFORT": "medium", "CODEPILOT_CODEX_BIN": codex, - "CODEPILOT_AGENT_THREAD_ID": threadID.trimmingCharacters(in: .whitespacesAndNewlines) + "CODEPILOT_AGENT_THREAD_ID": threadID ], keepAlive: false, startInterval: 60 @@ -214,6 +219,7 @@ enum CodePilotHostServicesManager { "Label": label, "ProgramArguments": programArguments, "RunAtLoad": true, + "Umask": 0o077, "WorkingDirectory": workingDirectory, "StandardOutPath": stdout, "StandardErrorPath": stderr, @@ -227,6 +233,7 @@ enum CodePilotHostServicesManager { } let data = try PropertyListSerialization.data(fromPropertyList: payload, format: .xml, options: 0) try data.write(to: plist, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: plist.path) runLaunchctl(["unload", plist.path]) runLaunchctl(["load", plist.path]) } @@ -3259,6 +3266,13 @@ private final class GatewayRemoteInputValidator: RemoteInputLeaseValidating { } } +enum RemoteDesktopHostPolicy { + // The native RPC host still lacks lease authorization for screen capture, + // input injection, and WebRTC signaling. Keep it unreachable until those + // checks are enforced end to end. + static let isEnabled = false +} + final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let switcher = CodexAccountSwitcher() private let remoteFrameCaptureService = RemoteFrameCaptureService() @@ -3284,16 +3298,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { NSApp.setActivationPolicy(.accessory) CodePilotHostServicesManager.ensureConfiguredOnLaunch() - if !CGPreflightScreenCaptureAccess() { - _ = CGRequestScreenCaptureAccess() - } - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.button?.title = "CodePilot" menu.delegate = self statusItem.menu = menu - remoteDesktopCoordinator = try? RemoteDesktopCoordinator() - if let remoteDesktopCoordinator { + if RemoteDesktopHostPolicy.isEnabled { + remoteDesktopCoordinator = try? RemoteDesktopCoordinator() + } + if RemoteDesktopHostPolicy.isEnabled, let remoteDesktopCoordinator { do { let server = RemoteDesktopSocketServer { [weak self, weak remoteDesktopCoordinator] request in guard let self, let remoteDesktopCoordinator else { diff --git a/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift b/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift index c25b33e0..c2303d08 100644 --- a/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift +++ b/Tests/CodexAccountSwitcherTests/PairingStoreTests.swift @@ -11,7 +11,7 @@ final class PairingStoreTests: XCTestCase { let challenge = store.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) @@ -35,7 +35,7 @@ final class PairingStoreTests: XCTestCase { let expiredStore = try makeStore(clock: { expiredNow }) let expiredChallenge = expiredStore.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) @@ -56,7 +56,7 @@ final class PairingStoreTests: XCTestCase { let privateKey = P256.Signing.PrivateKey() let challenge = store.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) @@ -93,7 +93,7 @@ final class PairingStoreTests: XCTestCase { let wrongPrivateKey = P256.Signing.PrivateKey() let challenge = store.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) @@ -120,7 +120,7 @@ final class PairingStoreTests: XCTestCase { let privateKey = P256.Signing.PrivateKey() let challenge = store.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) @@ -148,7 +148,7 @@ final class PairingStoreTests: XCTestCase { let privateKey = P256.Signing.PrivateKey() let challenge = store.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) @@ -171,7 +171,7 @@ final class PairingStoreTests: XCTestCase { let privateKey = P256.Signing.PrivateKey() let challenge = store.issueChallenge( deviceID: "device-1", - name: "Taylor's iPhone", + name: "Test iPhone", publicKeyRawRepresentation: privateKey.publicKey.rawRepresentation, macName: "Studio Mac" ) diff --git a/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift index dc8ac6f1..2e87aec4 100644 --- a/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift +++ b/Tests/CodexAccountSwitcherTests/RemoteDesktopCoordinatorTests.swift @@ -4,6 +4,10 @@ import XCTest @testable import CodexAccountSwitcher final class RemoteDesktopCoordinatorTests: XCTestCase { + func testUnsafeNativeRemoteDesktopHostIsDisabled() { + XCTAssertFalse(RemoteDesktopHostPolicy.isEnabled) + } + func testSnapshotReflectsPermissionState() throws { let permissions = FakeRemoteDesktopPermissions( screenRecordingGranted: false, diff --git a/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md b/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md index 444e6ea6..d3575332 100644 --- a/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md +++ b/docs/PRODUCTION_READINESS_IMPLEMENTATION_PLAN.md @@ -323,7 +323,7 @@ curl -fsS -X POST http://127.0.0.1:8787/codexphone/api/build curl -fsS http://127.0.0.1:8787/codexphone/api/status ``` -Wait until `current.state == "complete"`, then verify the manifest and IPA from the approved OTA host, for example `https://ota.example.com`, return HTTP 200. +Wait until `current.state == "complete"`, then verify the manifest and IPA from `https://ota.example.com` return HTTP 200. - [ ] **Step 8: Commit** diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9bd409c4..81bf54d5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -20,11 +20,31 @@ Recommended: - Use HTTPS when remote. - Use a long random bearer token. +- The gateway refuses existing tokens shorter than 32 characters or containing non-URL-safe characters. - Rotate the token if a device is lost. - Restrict Cloudflare access where practical. +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. +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. + +Cloudflare remote verification sends the gateway token only to the HTTPS origin stored by the permanent-tunnel setup. It rejects alternate hosts, URL credentials, nonstandard ports, and URL extras. Reconfigure the permanent tunnel instead of overriding verification with another URL. + +Localhost web sessions use short-lived capability URLs so WebView subresources can load without exposing the gateway bearer token to page content. Do not share those URLs. Sessions are restricted to the selected loopback origin, expire after ten minutes, and have bounded request counts. + +Remote file previews are restricted to files uploaded through CodePilot by default. Advanced local setups can add explicit roots with the `CODEPILOT_FILE_DOWNLOAD_ROOTS` environment variable, using the platform path separator between roots. Never add a credential, SSH, cloud configuration, or account-auth directory. + +Uploaded attachment batches are retained for seven days. The gateway removes expired batches at startup and before saving another upload. Storage and cleanup stay within a private CodePilot upload directory and refuse symbolic links. + +Desktop sync transfers accept only a plain host or `user@host` destination. SSH options, remote paths, and whitespace are rejected before CodePilot exports project metadata. + +Remote account-login sessions expire after ten minutes, are limited to four at a time, and terminate their temporary login process when they expire or fail. +The temporary iOS OAuth callback listener binds only to IPv4 loopback and accepts a callback only when its path and single, non-empty OAuth state exactly match the active login request. + +The native remote-desktop host is disabled while screen capture, input injection, and signaling still lack end-to-end lease authorization. CodePilot does not request Screen Recording permission at launch. Do not re-enable the host until the documented trusted-device and active-lease checks are enforced on every privileged operation. + ## Account Switching CodePilot swaps local provider auth files. It waits for active turns to finish before automatic switching, but users should still avoid manually editing auth files while turns are running. diff --git a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md index 5d487549..fd097658 100644 --- a/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md +++ b/docs/superpowers/plans/2026-06-27-secure-remote-desktop.md @@ -636,7 +636,7 @@ cd /path/to/ios-release-worktree python3 scripts/dke_ota_build.py build --app codexphone --branch release ``` -Expected: status reaches `complete`; output reports the existing configured bundle ID; both public manifest and IPA return HTTP 200; the OTA install updates the configured CodePilot app rather than creating another instance. +Expected: status reaches `complete`; output reports bundle ID `com.example.codepilot`; both public manifest and IPA return HTTP 200; the OTA install updates the configured CodePilot app rather than creating another instance. - [ ] **Step 7: Commit** diff --git a/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md b/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md index d62920f3..3af39f3d 100644 --- a/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md +++ b/docs/superpowers/plans/2026-07-01-codepilot-launch-agent-system.md @@ -216,7 +216,7 @@ Do not ping the maintainer for: 4. **Prepare community launch** - Community Growth Agent drafts Reddit account profile and launch posts. - - Maintainer approves account/posting strategy. + - The maintainer approves account/posting strategy. - Agent schedules staged, rule-compliant posts. 5. **Start beta loop** @@ -229,7 +229,7 @@ Do not ping the maintainer for: - Revenue Agent proposes tiers and pricing. - Presence Agent updates website and App Store copy. - Security Agent signs off on public release. - - Maintainer approves pricing, legal, and App Store submission. + - The maintainer approves pricing, legal, and App Store submission. ## Proposed Recurring Automations diff --git a/docs/superpowers/plans/2026-07-03-total-credit-live-activity.md b/docs/superpowers/plans/2026-07-03-total-credit-live-activity.md index 5b958a31..292eb84f 100644 --- a/docs/superpowers/plans/2026-07-03-total-credit-live-activity.md +++ b/docs/superpowers/plans/2026-07-03-total-credit-live-activity.md @@ -282,7 +282,7 @@ POST to `http://127.0.0.1:8787/codexphone/api/build`, poll `/codexphone/api/stat - [ ] **Step 4: Verify public OTA artifacts** -Fetch the generated tokenized manifest and IPA URLs from the approved OTA host, for example `https://ota.example.com`. +Fetch the generated tokenized manifest and IPA URLs from `https://ota.example.com`. Expected: HTTP 200 for both, manifest bundle identifier `io.codepilot.iOS`, and the widget extension embedded in the IPA. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index 41277243..d37f59f8 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone import fcntl import hashlib +import ipaddress import json import mimetypes import os @@ -15,6 +16,7 @@ import secrets import shutil import sqlite3 +import stat import subprocess import tempfile import threading @@ -37,6 +39,7 @@ DEFAULT_SWITCHER_HOME = HOME / ".codex-account-switcher" DEFAULT_TOKEN_FILE = DEFAULT_SWITCHER_HOME / "phone-gateway-token" DEFAULT_UPLOADS_DIR = DEFAULT_SWITCHER_HOME / "phone-uploads" +MIN_GATEWAY_TOKEN_LENGTH = 32 DEFAULT_THREAD_MESSAGE_CACHE_DIR = DEFAULT_SWITCHER_HOME / "phone-thread-message-cache" DEFAULT_NOTIFICATION_DEVICES_FILE = DEFAULT_SWITCHER_HOME / "phone-notification-devices.json" DEFAULT_LIVE_ACTIVITIES_FILE = DEFAULT_SWITCHER_HOME / "phone-live-activities.json" @@ -46,9 +49,24 @@ "/opt/homebrew/sbin", "/usr/local/bin", ) +GATEWAY_ONLY_CHILD_ENV_KEYS = { + "CODEPILOT_FILE_DOWNLOAD_ROOTS", + "CODEPILOT_TURN_API_TOKEN", + "CODEPILOT_TURN_KEY_ID", + "CODEX_PHONE_APNS_CERT_KEY_PATH", + "CODEX_PHONE_APNS_CERT_PATH", + "CODEX_PHONE_APNS_KEY_ID", + "CODEX_PHONE_APNS_KEY_PATH", + "CODEX_PHONE_APNS_TEAM_ID", + "CODEX_PHONE_APNS_TOPIC", + "SUPABASE_ACCESS_TOKEN", +} MAX_ATTACHMENTS = 8 MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024 +MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 +MAX_ATTACHMENT_REQUEST_BYTES = 72 * 1024 * 1024 +UPLOAD_RETENTION_SECONDS = 7 * 24 * 60 * 60 VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} PUBLIC_JOB_TEXT_LIMIT = 4_000 PUBLIC_EVENT_BODY_LIMIT = 12_000 @@ -70,8 +88,14 @@ REMOTE_LOGIN_URL_RE = re.compile(r"https?://\S+") REMOTE_LOGIN_URL_TIMEOUT_SECONDS = 15 REMOTE_LOGIN_CALLBACK_TIMEOUT_SECONDS = 30 -LOCAL_WEB_SESSION_TIMEOUT_SECONDS = 60 * 60 +REMOTE_LOGIN_SESSION_TIMEOUT_SECONDS = 10 * 60 +REMOTE_LOGIN_MAX_ACTIVE_SESSIONS = 4 +LOCAL_WEB_SESSION_TIMEOUT_SECONDS = 10 * 60 +LOCAL_WEB_MAX_ACTIVE_SESSIONS = 8 +LOCAL_WEB_MAX_REQUESTS_PER_SESSION = 256 LOCAL_WEB_MAX_BYTES = 25 * 1024 * 1024 +GATEWAY_MAX_CONCURRENT_REQUESTS = 64 +GATEWAY_REQUEST_TIMEOUT_SECONDS = 30.0 JOBS = {} JOB_PROCESSES = {} @@ -81,6 +105,8 @@ def codex_child_env(codex_home: Path) -> dict: env = os.environ.copy() + for key in GATEWAY_ONLY_CHILD_ENV_KEYS: + env.pop(key, None) env["CODEX_HOME"] = str(codex_home) existing_path = env.get("PATH") or os.defpath path_parts = [part for part in existing_path.split(os.pathsep) if part] @@ -250,9 +276,9 @@ def turn_start(self, thread_id: str, text: str, reasoning_effort: str | None = N params = { "threadId": thread_id, "input": [self.text_input(text)], - "approvalPolicy": "never", + "approvalPolicy": "never" if self.allow_dangerous else "on-request", "sandboxPolicy": { - "type": "dangerFullAccess", + "type": "dangerFullAccess" if self.allow_dangerous else "workspaceWrite", }, } if reasoning_effort: @@ -380,14 +406,37 @@ def _reader_loop(self): def read_or_create_token(path: Path) -> str: if path.exists(): - return path.read_text(encoding="utf-8").strip() - path.parent.mkdir(parents=True, exist_ok=True) + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise RuntimeError("Gateway token path must be a regular file") + os.chmod(path, 0o600) + token = path.read_text(encoding="utf-8").strip() + if not token: + raise RuntimeError("Gateway token file is empty") + if len(token) < MIN_GATEWAY_TOKEN_LENGTH or re.fullmatch(r"[A-Za-z0-9_-]+", token) is None: + raise RuntimeError( + f"Gateway token must contain at least {MIN_GATEWAY_TOKEN_LENGTH} URL-safe characters; rotate it before restarting" + ) + return token + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(path.parent, 0o700) token = secrets.token_urlsafe(32) - path.write_text(token + "\n", encoding="utf-8") - os.chmod(path, 0o600) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(token + "\n") return token +def is_loopback_host(host: str) -> bool: + value = str(host or "").strip().strip("[]") + if value.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(value).is_loopback + except ValueError: + return False + + def base64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") @@ -447,21 +496,17 @@ def send_live_activity_to_device(self, registration: dict, content_state: dict) host = "api.sandbox.push.apple.com" if environment == "development" else "api.push.apple.com" bundle_id = str(registration.get("bundleId") or self.default_topic).strip() or self.default_topic payload = live_activity_payload(content_state) - process = subprocess.run( + process = run_apns_curl( + f"https://{host}/3/device/{token}", [ - "curl", "--http2", "-sS", "-X", "POST", - "--cert", str(self.cert_path), "--key", str(self.key_path), - "-H", f"apns-topic: {bundle_id}.push-type.liveactivity", - "-H", "apns-push-type: liveactivity", - "-H", "apns-priority: 10", - "--write-out", "\n%{http_code}", - "--data-binary", "@-", - f"https://{host}/3/device/{token}", + f"apns-topic: {bundle_id}.push-type.liveactivity", + "apns-push-type: liveactivity", + "apns-priority: 10", ], - input=payload, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, + payload, + cert_path=self.cert_path, + key_path=self.key_path, + include_status=True, ) return apns_http_status(process.stdout) @@ -483,24 +528,17 @@ def send_to_device(self, device: dict, notification: dict): "threadId": notification.get("threadId", ""), "jobId": notification.get("jobId", ""), }, separators=(",", ":")).encode("utf-8") - subprocess.run( + run_apns_curl( + f"https://{host}/3/device/{token}", [ - "curl", - "--http2", - "-fsS", - "-X", "POST", - "--cert", str(self.cert_path), - "--key", str(self.key_path), - "-H", f"apns-topic: {topic}", - "-H", "apns-push-type: alert", - "-H", "apns-priority: 10", - "--data-binary", "@-", - f"https://{host}/3/device/{token}", + f"apns-topic: {topic}", + "apns-push-type: alert", + "apns-priority: 10", ], - input=payload, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, + payload, + cert_path=self.cert_path, + key_path=self.key_path, + fail_on_http_error=True, ) @@ -571,21 +609,16 @@ def send_live_activity_to_device(self, registration: dict, content_state: dict) environment = str(registration.get("environment") or "production").strip().lower() host = "api.sandbox.push.apple.com" if environment == "development" else "api.push.apple.com" bundle_id = str(registration.get("bundleId") or self.default_topic).strip() or self.default_topic - process = subprocess.run( + process = run_apns_curl( + f"https://{host}/3/device/{token}", [ - "curl", "--http2", "-sS", "-X", "POST", - "-H", f"authorization: bearer {self.jwt()}", - "-H", f"apns-topic: {bundle_id}.push-type.liveactivity", - "-H", "apns-push-type: liveactivity", - "-H", "apns-priority: 10", - "--write-out", "\n%{http_code}", - "--data-binary", "@-", - f"https://{host}/3/device/{token}", + f"authorization: bearer {self.jwt()}", + f"apns-topic: {bundle_id}.push-type.liveactivity", + "apns-push-type: liveactivity", + "apns-priority: 10", ], - input=live_activity_payload(content_state), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, + live_activity_payload(content_state), + include_status=True, ) return apns_http_status(process.stdout) @@ -607,23 +640,16 @@ def send_to_device(self, device: dict, notification: dict): "threadId": notification.get("threadId", ""), "jobId": notification.get("jobId", ""), }, separators=(",", ":")).encode("utf-8") - subprocess.run( + run_apns_curl( + f"https://{host}/3/device/{token}", [ - "curl", - "--http2", - "-fsS", - "-X", "POST", - "-H", f"authorization: bearer {self.jwt()}", - "-H", f"apns-topic: {topic}", - "-H", "apns-push-type: alert", - "-H", "apns-priority: 10", - "--data-binary", "@-", - f"https://{host}/3/device/{token}", + f"authorization: bearer {self.jwt()}", + f"apns-topic: {topic}", + "apns-push-type: alert", + "apns-priority: 10", ], - input=payload, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, + payload, + fail_on_http_error=True, ) @@ -637,6 +663,42 @@ def live_activity_payload(content_state: dict) -> bytes: }, separators=(",", ":")).encode("utf-8") +def run_apns_curl( + url: str, + headers: list[str], + payload: bytes, + *, + cert_path: Path | None = None, + key_path: Path | None = None, + include_status: bool = False, + fail_on_http_error: bool = False, +): + config_lines = [ + f'url = "{curl_config_value(url)}"', + 'request = "POST"', + ] + if cert_path is not None: + config_lines.append(f'cert = "{curl_config_value(str(cert_path))}"') + if key_path is not None: + config_lines.append(f'key = "{curl_config_value(str(key_path))}"') + config_lines.extend(f'header = "{curl_config_value(header)}"' for header in headers) + if include_status: + config_lines.append('write-out = "\\n%{http_code}"') + config_lines.append(f'data-binary = "{curl_config_value(payload.decode("utf-8"))}"') + config = "\n".join([*config_lines, ""]) + command = ["curl", "--http2", "-sS"] + if fail_on_http_error: + command.append("--fail") + command.extend(["--config", "-"]) + return subprocess.run( + command, + input=config.encode("utf-8"), + stdout=subprocess.PIPE if include_status else subprocess.DEVNULL, + stderr=subprocess.PIPE if include_status else subprocess.DEVNULL, + check=False, + ) + + def apns_http_status(output: bytes) -> int: try: return int((output or b"").rsplit(b"\n", 1)[-1]) @@ -657,6 +719,8 @@ def json_response(handler, status: int, payload: dict): handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Cache-Control", "no-store") + handler.send_header("Referrer-Policy", "no-referrer") + handler.send_header("X-Content-Type-Options", "nosniff") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) @@ -687,14 +751,20 @@ def decode_body(handler, max_length: int | None = None) -> dict: raw_length = handler.headers.get("content-length", "0") try: length = int(raw_length) - except ValueError: - length = 0 + except (TypeError, ValueError) as exc: + raise ValueError("invalid_content_length") from exc + if length < 0: + raise ValueError("invalid_content_length") if length <= 0: return {} - if max_length is not None and length > max_length: + effective_max_length = MAX_JSON_BODY_BYTES if max_length is None else max_length + if length > effective_max_length: raise RequestBodyTooLarge("request_too_large") body = handler.rfile.read(length) - return json.loads(body.decode("utf-8")) + decoded = json.loads(body.decode("utf-8")) + if not isinstance(decoded, dict): + raise ValueError("request_body_must_be_an_object") + return decoded def safe_filename(name: str, fallback: str) -> str: @@ -712,7 +782,74 @@ def is_image_attachment(path: Path, mime_type: str) -> bool: return path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} -def resolve_requested_file_path(raw_path: str) -> Path: +def cleanup_expired_uploads( + root: Path | None = None, + *, + now: float | None = None, + retention_seconds: int = UPLOAD_RETENTION_SECONDS, +) -> int: + """Remove expired upload batches without following links outside the upload root.""" + if retention_seconds <= 0: + raise ValueError("Upload retention must be positive") + root = DEFAULT_UPLOADS_DIR if root is None else root + + try: + root_stat = root.lstat() + except FileNotFoundError: + return 0 + if stat.S_ISLNK(root_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode): + return 0 + + cutoff = (time.time() if now is None else now) - retention_seconds + removed = 0 + for thread_dir in root.iterdir(): + try: + thread_stat = thread_dir.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(thread_stat.st_mode) or not stat.S_ISDIR(thread_stat.st_mode): + continue + + for upload_dir in thread_dir.iterdir(): + try: + upload_stat = upload_dir.lstat() + except FileNotFoundError: + continue + if ( + stat.S_ISLNK(upload_stat.st_mode) + or not stat.S_ISDIR(upload_stat.st_mode) + or upload_stat.st_mtime > cutoff + ): + continue + shutil.rmtree(upload_dir) + removed += 1 + + try: + thread_dir.rmdir() + except OSError: + pass + return removed + + +def ensure_private_upload_directory(path: Path, *, parents: bool = False) -> None: + try: + path_stat = path.lstat() + except FileNotFoundError: + path.mkdir(mode=0o700, parents=parents) + path_stat = path.lstat() + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISDIR(path_stat.st_mode): + raise RuntimeError("Upload path must be a real directory") + os.chmod(path, 0o700) + + +def configured_download_roots() -> list[Path]: + roots = [DEFAULT_UPLOADS_DIR] + configured = os.environ.get("CODEPILOT_FILE_DOWNLOAD_ROOTS", "") + roots.extend(Path(value).expanduser() for value in configured.split(os.pathsep) if value.strip()) + return [root.resolve(strict=False) for root in roots] + + +def resolve_requested_file_path(raw_path: str, allowed_roots: list[Path] | None = None) -> Path: value = str(raw_path or "").strip() if not value: raise ValueError("Missing file path") @@ -739,6 +876,11 @@ def resolve_requested_file_path(raw_path: str) -> Path: if not resolved.is_file(): raise LookupError(f"Not a file: {resolved}") + roots = configured_download_roots() if allowed_roots is None else allowed_roots + normalized_roots = [root.expanduser().resolve(strict=False) for root in roots] + if not any(resolved == root or root in resolved.parents for root in normalized_roots): + raise PermissionError("File is outside the configured CodePilot download roots") + return resolved @@ -760,6 +902,8 @@ def file_response(handler: BaseHTTPRequestHandler, path: Path): handler.send_response(200) handler.send_header("Content-Type", metadata["mimeType"]) handler.send_header("Cache-Control", "no-store") + handler.send_header("Referrer-Policy", "no-referrer") + handler.send_header("X-Content-Type-Options", "nosniff") handler.send_header("Content-Length", str(metadata["size"])) handler.send_header("Content-Disposition", f'attachment; filename="{download_name}"') handler.end_headers() @@ -775,6 +919,8 @@ def local_web_response(handler: BaseHTTPRequestHandler, payload: dict): handler.send_response(status) handler.send_header("Content-Type", str(payload.get("contentType") or "application/octet-stream")) handler.send_header("Cache-Control", "no-store") + handler.send_header("Referrer-Policy", "no-referrer") + handler.send_header("X-Content-Type-Options", "nosniff") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) @@ -804,6 +950,21 @@ def compact_json(value, limit: int = 900) -> str: return truncate_text(str(value), limit) +class LoopbackOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + source = urllib.parse.urlparse(req.full_url) + target = urllib.parse.urlparse(urllib.parse.urljoin(req.full_url, newurl)) + source_port = source.port or (443 if source.scheme == "https" else 80) + target_port = target.port or (443 if target.scheme == "https" else 80) + if ( + target.scheme != source.scheme + or (target.hostname or "").casefold() not in {"localhost", "127.0.0.1", "::1"} + or target_port != source_port + ): + raise RuntimeError("Local web redirect left the selected loopback origin") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + def fetch_local_web_url(url: str) -> tuple[int, dict, bytes]: request = urllib.request.Request( url, @@ -812,8 +973,9 @@ def fetch_local_web_url(url: str) -> tuple[int, dict, bytes]: "Accept": "*/*", }, ) + opener = urllib.request.build_opener(LoopbackOnlyRedirectHandler()) try: - with urllib.request.urlopen(request, timeout=30) as response: + with opener.open(request, timeout=30) as response: body = response.read(LOCAL_WEB_MAX_BYTES + 1) if len(body) > LOCAL_WEB_MAX_BYTES: raise RuntimeError("Local web response is too large") @@ -1728,7 +1890,8 @@ def cache_thread_message(thread_id: str, message_id: str, text: str, timestamp: return path = message_cache_path(thread_id) - path.parent.mkdir(parents=True, exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(path.parent, 0o700) try: payload = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} except (OSError, json.JSONDecodeError): @@ -1745,9 +1908,8 @@ def cache_thread_message(thread_id: str, message_id: str, text: str, timestamp: "timestamp": datetime.fromtimestamp(event_time, timezone.utc).isoformat(), "updatedAt": event_time, } - tmp_path = path.with_suffix(".tmp") - tmp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - tmp_path.replace(path) + write_json_object_atomic(path, payload) + os.chmod(path, 0o600) def message_sort_timestamp(message: dict) -> float | None: @@ -1948,6 +2110,7 @@ def __init__( self._local_web_lock = threading.Lock() self._remote_login_sessions = {} self._remote_login_lock = threading.Lock() + self._remote_login_start_lock = threading.Lock() @property def db_path(self) -> Path: @@ -3262,20 +3425,13 @@ def send_turn_completion_push(self, job: dict): self.push_notifier.send_turn_completion(devices, notification) def turn_completion_notification(self, job: dict) -> dict: - thread_title = str(job.get("threadTitle") or "").strip() - if not thread_title: - thread_title = self.thread_title_for_notification(str(job.get("threadId") or "")) - if not thread_title: - thread_title = "Codex thread" - status = str(job.get("status") or "") if status == "failed": title = "Codex failed" - error = str(job.get("error") or "").strip() - body = f"{thread_title}: {truncate_text(error, 120)}" if error else f"Turn failed in {thread_title}." + body = "Open CodePilot to review the error." else: title = "Codex finished" - body = f"Turn finished in {thread_title}." + body = "Open CodePilot to view the result." return { "title": title, "body": body, @@ -3283,17 +3439,6 @@ def turn_completion_notification(self, job: dict) -> dict: "jobId": str(job.get("id") or ""), } - def thread_title_for_notification(self, thread_id: str) -> str: - if not thread_id: - return "" - try: - thread = self.get_thread_state_db(thread_id) - except Exception: - return "" - if not thread: - return "" - return str(thread.get("title") or "").strip() - def switch_account(self, raw_name: str) -> dict: account_name = self.resolve_account_profile_name(raw_name) @@ -3506,6 +3651,11 @@ def start_remote_new_account_login(self, raw_name: str) -> dict: return self.start_remote_login_session(account_name, mode="add") def start_remote_login_session(self, account_name: str, mode: str) -> dict: + with self._remote_login_start_lock: + self.ensure_remote_login_capacity() + return self._start_remote_login_session(account_name, mode) + + def _start_remote_login_session(self, account_name: str, mode: str) -> dict: DEFAULT_SWITCHER_HOME.mkdir(parents=True, exist_ok=True) session_id = secrets.token_urlsafe(16) temp_codex_home = Path(tempfile.mkdtemp(prefix="codex-remote-login-", dir=str(DEFAULT_SWITCHER_HOME))) @@ -3570,6 +3720,11 @@ def read_output(): raise RuntimeError(f"Codex login did not provide a browser login URL. {truncate_text(output, 500)}") def start_remote_mcp_login(self, raw_name: str) -> dict: + with self._remote_login_start_lock: + self.ensure_remote_login_capacity() + return self._start_remote_mcp_login(raw_name) + + def _start_remote_mcp_login(self, raw_name: str) -> dict: server_name = str(raw_name or "").strip() if not server_name: raise ValueError("MCP server name is required") @@ -3658,6 +3813,7 @@ def public_remote_login_session(self, session: dict) -> dict: } def remote_account_login_status(self, session_id: str) -> dict: + self.cleanup_expired_remote_login_sessions() with self._remote_login_lock: session = self._remote_login_sessions.get(str(session_id or "").strip()) if not session: @@ -3665,6 +3821,7 @@ def remote_account_login_status(self, session_id: str) -> dict: return self.public_remote_login_session(session) def complete_remote_account_login(self, session_id: str, callback_url: str) -> dict: + self.cleanup_expired_remote_login_sessions() session_id = str(session_id or "").strip() with self._remote_login_lock: session = self._remote_login_sessions.get(session_id) @@ -3674,30 +3831,35 @@ def complete_remote_account_login(self, session_id: str, callback_url: str) -> d raise ValueError("Remote login session is not an account login") session["status"] = "completing" - local_callback_url = self.validated_remote_login_callback_url(session, callback_url) - self.relay_remote_login_callback(local_callback_url) - - process = session["process"] try: + local_callback_url = self.validated_remote_login_callback_url(session, callback_url) + self.relay_remote_login_callback(local_callback_url) + + process = session["process"] returncode = process.wait(timeout=REMOTE_LOGIN_CALLBACK_TIMEOUT_SECONDS) - except subprocess.TimeoutExpired as exc: - raise RuntimeError("Timed out waiting for Codex login to finish") from exc - if returncode != 0: - output = str(session.get("output") or "").strip() - raise RuntimeError(f"Codex login failed: {truncate_text(output or f'exit {returncode}', 500)}") + if returncode != 0: + output = str(session.get("output") or "").strip() + raise RuntimeError(f"Codex login failed: {truncate_text(output or f'exit {returncode}', 500)}") - generated_auth = Path(session["tempCodexHome"]) / "auth.json" - if not generated_auth.is_file(): - raise RuntimeError("Codex login did not produce auth.json") + generated_auth = Path(session["tempCodexHome"]) / "auth.json" + if not generated_auth.is_file(): + raise RuntimeError("Codex login did not produce auth.json") - if str(session.get("mode") or "") == "add": - self.install_new_account_auth(str(session["accountName"]), generated_auth) - else: - self.install_refreshed_account_auth(str(session["accountName"]), generated_auth) + if str(session.get("mode") or "") == "add": + self.install_new_account_auth(str(session["accountName"]), generated_auth) + else: + self.install_refreshed_account_auth(str(session["accountName"]), generated_auth) + except subprocess.TimeoutExpired as exc: + self.cleanup_remote_account_login_session(session_id, terminate=True) + raise RuntimeError("Timed out waiting for Codex login to finish") from exc + except Exception: + self.cleanup_remote_account_login_session(session_id, terminate=True) + raise self.cleanup_remote_account_login_session(session_id, terminate=False) return self.account_status_snapshot() def complete_remote_mcp_login(self, session_id: str, callback_url: str) -> dict: + self.cleanup_expired_remote_login_sessions() session_id = str(session_id or "").strip() with self._remote_login_lock: session = self._remote_login_sessions.get(session_id) @@ -3707,19 +3869,23 @@ def complete_remote_mcp_login(self, session_id: str, callback_url: str) -> dict: raise ValueError("Remote login session is not an MCP login") session["status"] = "completing" - local_callback_url = self.validated_remote_login_callback_url(session, callback_url) - self.relay_remote_login_callback(local_callback_url) - - process = session["process"] try: + local_callback_url = self.validated_remote_login_callback_url(session, callback_url) + self.relay_remote_login_callback(local_callback_url) + + process = session["process"] returncode = process.wait(timeout=REMOTE_LOGIN_CALLBACK_TIMEOUT_SECONDS) + if returncode != 0: + output = str(session.get("output") or "").strip() + raise RuntimeError(f"Codex MCP login failed: {truncate_text(output or f'exit {returncode}', 500)}") + + self.clear_connector_auth_warning(str(session.get("accountName") or "")) except subprocess.TimeoutExpired as exc: + self.cleanup_remote_account_login_session(session_id, terminate=True) raise RuntimeError("Timed out waiting for Codex MCP login to finish") from exc - if returncode != 0: - output = str(session.get("output") or "").strip() - raise RuntimeError(f"Codex MCP login failed: {truncate_text(output or f'exit {returncode}', 500)}") - - self.clear_connector_auth_warning(str(session.get("accountName") or "")) + except Exception: + self.cleanup_remote_account_login_session(session_id, terminate=True) + raise self.cleanup_remote_account_login_session(session_id, terminate=False) return self.account_status_snapshot() @@ -3731,8 +3897,14 @@ def validated_remote_login_callback_url(self, session: dict, callback_url: str) auth_url = str(session.get("authUrl") or "") auth_query = urllib.parse.parse_qs(urllib.parse.urlparse(auth_url).query) - expected_redirect = (auth_query.get("redirect_uri") or [""])[0] - expected_state = (auth_query.get("state") or [""])[0] + redirect_values = auth_query.get("redirect_uri") or [] + state_values = auth_query.get("state") or [] + if len(redirect_values) != 1 or not redirect_values[0]: + raise ValueError("Remote login redirect URL is invalid") + if len(state_values) != 1 or not state_values[0]: + raise ValueError("Remote login authorization state is invalid") + expected_redirect = redirect_values[0] + expected_state = state_values[0] redirect = urllib.parse.urlparse(expected_redirect) if redirect.scheme != "http" or redirect.hostname not in {"localhost", "127.0.0.1"}: raise ValueError("Remote login redirect URL is invalid") @@ -3742,8 +3914,8 @@ def validated_remote_login_callback_url(self, session: dict, callback_url: str) raise ValueError("Remote login callback path is invalid") callback_query = urllib.parse.parse_qs(parsed.query) - callback_state = (callback_query.get("state") or [""])[0] - if expected_state and callback_state != expected_state: + callback_state_values = callback_query.get("state") or [] + if len(callback_state_values) != 1 or callback_state_values[0] != expected_state: raise ValueError("Remote login callback state does not match") if callback_query.get("error"): error = (callback_query.get("error_description") or callback_query.get("error") or ["Login failed"])[0] @@ -3764,6 +3936,24 @@ def cancel_remote_account_login(self, session_id: str) -> dict: self.cleanup_remote_account_login_session(session_id, terminate=True) return {"ok": True} + def ensure_remote_login_capacity(self): + self.cleanup_expired_remote_login_sessions() + with self._remote_login_lock: + if len(self._remote_login_sessions) >= REMOTE_LOGIN_MAX_ACTIVE_SESSIONS: + raise ValueError("Too many remote login sessions are active; cancel one and try again") + + def cleanup_expired_remote_login_sessions(self, now: int | None = None) -> int: + current_time = int(time.time()) if now is None else int(now) + with self._remote_login_lock: + expired_ids = [ + session_id + for session_id, session in self._remote_login_sessions.items() + if int(session.get("createdAt") or 0) + REMOTE_LOGIN_SESSION_TIMEOUT_SECONDS <= current_time + ] + for session_id in expired_ids: + self.cleanup_remote_account_login_session(session_id, terminate=True) + return len(expired_ids) + def cleanup_remote_account_login_session(self, session_id: str, terminate: bool): with self._remote_login_lock: session = self._remote_login_sessions.pop(session_id, None) @@ -3796,10 +3986,17 @@ def start_local_web_session(self, raw_url: str) -> dict: "createdAt": now, "expiresAt": expires_at, "targetOrigin": target_origin, + "requestCount": 0, } with self._local_web_lock: - self._local_web_sessions[session_id] = session self.cleanup_expired_local_web_sessions_locked(now) + while len(self._local_web_sessions) >= LOCAL_WEB_MAX_ACTIVE_SESSIONS: + oldest_session_id = min( + self._local_web_sessions, + key=lambda candidate: int(self._local_web_sessions[candidate].get("createdAt") or 0), + ) + self._local_web_sessions.pop(oldest_session_id, None) + self._local_web_sessions[session_id] = session path = self.local_web_gateway_path(session_id, parsed.path or "/", parsed.query) return { "sessionId": session_id, @@ -3842,6 +4039,11 @@ def proxy_local_web_session(self, session_id: str, path_parts: list[str], query: with self._local_web_lock: self.cleanup_expired_local_web_sessions_locked(now) session = self._local_web_sessions.get(str(session_id or "")) + if session: + session["requestCount"] = int(session.get("requestCount") or 0) + 1 + if session["requestCount"] > LOCAL_WEB_MAX_REQUESTS_PER_SESSION: + self._local_web_sessions.pop(str(session_id or ""), None) + session = None if not session: raise LookupError("Local web session not found or expired") @@ -4240,46 +4442,69 @@ def save_attachments(self, thread_id: str, job_id: str, raw_attachments: list) - if len(raw_attachments) > MAX_ATTACHMENTS: raise ValueError(f"Too many attachments; maximum is {MAX_ATTACHMENTS}") + cleanup_expired_uploads() + if not raw_attachments: + return [] saved = [] total_size = 0 - upload_dir = DEFAULT_UPLOADS_DIR / safe_filename(thread_id, "thread") / f"{int(time.time())}-{job_id}" - for index, attachment in enumerate(raw_attachments, 1): - if not isinstance(attachment, dict): - raise ValueError("Attachment is invalid") + ensure_private_upload_directory(DEFAULT_UPLOADS_DIR, parents=True) + thread_upload_dir = DEFAULT_UPLOADS_DIR / safe_filename(thread_id, "thread") + ensure_private_upload_directory(thread_upload_dir) + upload_dir = thread_upload_dir / f"{int(time.time())}-{safe_filename(job_id, 'job')}" + ensure_private_upload_directory(upload_dir) + created_paths = [] + try: + for index, attachment in enumerate(raw_attachments, 1): + if not isinstance(attachment, dict): + raise ValueError("Attachment is invalid") - encoded = attachment.get("dataBase64") - if not isinstance(encoded, str) or not encoded: - raise ValueError("Attachment data is missing") + encoded = attachment.get("dataBase64") + if not isinstance(encoded, str) or not encoded: + raise ValueError("Attachment data is missing") + try: + data = base64.b64decode(encoded, validate=True) + except Exception as exc: + raise ValueError("Attachment is not valid base64") from exc + + size = len(data) + if size > MAX_ATTACHMENT_BYTES: + raise ValueError("Attachment is too large") + total_size += size + if total_size > MAX_TOTAL_ATTACHMENT_BYTES: + raise ValueError("Total attachment size is too large") + + filename = safe_filename(str(attachment.get("filename", "")), f"attachment-{index}") + path = upload_dir / filename + if path.exists(): + stem = path.stem or "attachment" + suffix = path.suffix + path = upload_dir / f"{stem}-{index}{suffix}" + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + created_paths.append(path) + with os.fdopen(descriptor, "wb") as handle: + handle.write(data) + + mime_type = str(attachment.get("mimeType") or "application/octet-stream") + saved.append({ + "filename": path.name, + "mimeType": mime_type, + "size": size, + "path": path, + "isImage": is_image_attachment(path, mime_type), + }) + except Exception: + for path in created_paths: + try: + path.unlink() + except OSError: + pass try: - data = base64.b64decode(encoded, validate=True) - except Exception as exc: - raise ValueError("Attachment is not valid base64") from exc - - size = len(data) - if size > MAX_ATTACHMENT_BYTES: - raise ValueError("Attachment is too large") - total_size += size - if total_size > MAX_TOTAL_ATTACHMENT_BYTES: - raise ValueError("Total attachment size is too large") - - upload_dir.mkdir(parents=True, exist_ok=True) - filename = safe_filename(str(attachment.get("filename", "")), f"attachment-{index}") - path = upload_dir / filename - if path.exists(): - stem = path.stem or "attachment" - suffix = path.suffix - path = upload_dir / f"{stem}-{index}{suffix}" - path.write_bytes(data) - - mime_type = str(attachment.get("mimeType") or "application/octet-stream") - saved.append({ - "filename": path.name, - "mimeType": mime_type, - "size": size, - "path": path, - "isImage": is_image_attachment(path, mime_type), - }) + upload_dir.rmdir() + thread_upload_dir.rmdir() + except OSError: + pass + raise return saved @@ -4417,7 +4642,10 @@ def run_turn_exec( ): env = codex_child_env(self.codex_home) codex = str(self.codex_path if self.codex_path.exists() else shutil.which("codex") or DEFAULT_CODEX) - output_file = Path(tempfile.gettempdir()) / f"codex-phone-{job_id}.txt" + output_descriptor, output_name = tempfile.mkstemp(prefix=f"codex-phone-{job_id}-", suffix=".txt") + os.close(output_descriptor) + output_file = Path(output_name) + os.chmod(output_file, 0o600) args = [ codex, "exec", @@ -4519,6 +4747,11 @@ def run_turn_exec( push_job = self.prepare_turn_completion_push_locked(job) if push_job: self.send_turn_completion_push(push_job) + finally: + try: + output_file.unlink() + except FileNotFoundError: + pass class Handler(BaseHTTPRequestHandler): @@ -4529,19 +4762,17 @@ def state(self) -> GatewayState: def do_OPTIONS(self): self.send_response(204) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Headers", "authorization, content-type") - self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + self.send_header("Allow", "GET, POST, DELETE, OPTIONS") + self.send_header("Cache-Control", "no-store") self.end_headers() def end_headers(self): - self.send_header("Access-Control-Allow-Origin", "*") super().end_headers() def authenticate(self) -> bool: expected = self.state().token header = self.headers.get("authorization", "") - if header == f"Bearer {expected}": + if secrets.compare_digest(header, f"Bearer {expected}"): return True json_error( self, @@ -4696,6 +4927,14 @@ def do_GET(self): return json_error(self, 404, "gateway_unavailable", "Not found", "Update the app or gateway if this request should be supported.") + except PermissionError as exc: + json_error( + self, + 403, + "file_access_denied", + str(exc), + "Only preview files from a configured CodePilot download root.", + ) except LookupError as exc: json_error(self, 404, "gateway_unavailable", str(exc), "Refresh the app and try again.") except Exception as exc: @@ -4710,7 +4949,7 @@ def do_POST(self): try: if len(parts) == 2 and parts[0] == "api" and parts[1] == "threads": - body = decode_body(self) + body = decode_body(self, max_length=MAX_ATTACHMENT_REQUEST_BYTES) attachments = body.get("attachments") job = self.state().start_new_thread( str(body.get("cwd", "")), @@ -4812,7 +5051,7 @@ def do_POST(self): return if len(parts) == 4 and parts[0] == "api" and parts[1] == "threads" and parts[3] == "turns": - body = decode_body(self) + body = decode_body(self, max_length=MAX_ATTACHMENT_REQUEST_BYTES) attachments = body.get("attachments") job = self.state().start_turn( parts[2], @@ -4890,17 +5129,70 @@ def do_DELETE(self): except Exception as exc: json_error(self, 500, "gateway_unavailable", str(exc), "Restart CodePilot Gateway if the problem continues.") + def log_request(self, code="-", size="-"): + parsed = urllib.parse.urlsplit(self.path) + path = parsed.path + if path.startswith("/api/local-web/"): + path = "/api/local-web/[redacted]" + self.log_message( + '"%s %s %s" %s %s', + self.command, + path, + self.request_version, + str(code), + str(size), + ) + def log_message(self, format, *args): - print(f"{self.address_string()} - {format % args}", flush=True) + message = format % args + safe_message = re.sub( + r"[\x00-\x1f\x7f-\x9f]", + lambda match: f"\\x{ord(match.group(0)):02x}", + message, + ) + print(f"{self.client_address[0]} - {safe_message}", flush=True) class GatewayServer(ThreadingHTTPServer): allow_reuse_address = True + daemon_threads = True + block_on_close = False + request_queue_size = GATEWAY_MAX_CONCURRENT_REQUESTS - def __init__(self, address, state: GatewayState): + def __init__( + self, + address, + state: GatewayState, + *, + max_concurrent_requests: int = GATEWAY_MAX_CONCURRENT_REQUESTS, + request_timeout_seconds: float = GATEWAY_REQUEST_TIMEOUT_SECONDS, + ): + self._request_slots = threading.BoundedSemaphore(max(1, int(max_concurrent_requests))) + self.request_timeout_seconds = max(1.0, float(request_timeout_seconds)) super().__init__(address, Handler) self.state = state + def get_request(self): + request, client_address = super().get_request() + request.settimeout(self.request_timeout_seconds) + return request, client_address + + def process_request(self, request, client_address): + if not self._request_slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except Exception: + self._request_slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self._request_slots.release() + def main(): parser = argparse.ArgumentParser(description="Small HTTP gateway for the Codex iPhone client.") @@ -4910,9 +5202,18 @@ def main(): parser.add_argument("--codex-path", default=str(DEFAULT_CODEX)) parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE)) parser.add_argument("--allow-dangerous", action=argparse.BooleanOptionalAction, default=False) + parser.add_argument( + "--allow-non-loopback", + action="store_true", + help="Allow direct binding outside loopback. Use only behind a trusted TLS/authentication proxy.", + ) args = parser.parse_args() + if not is_loopback_host(args.host) and not args.allow_non_loopback: + parser.error("Refusing a non-loopback bind without --allow-non-loopback") + token = read_or_create_token(Path(args.token_file)) + cleanup_expired_uploads() state = GatewayState( codex_home=Path(args.codex_home), token=token, diff --git a/gateway/remote_desktop_gateway.py b/gateway/remote_desktop_gateway.py index df158f9d..99297c3e 100644 --- a/gateway/remote_desktop_gateway.py +++ b/gateway/remote_desktop_gateway.py @@ -187,10 +187,15 @@ def __init__( host_client: NativeHostClient | None = None, signal_queue_limit: int = SIGNAL_QUEUE_LIMIT, turn_urlopen=urllib.request.urlopen, + remote_control_enabled: bool = False, ): self.host_client = host_client if host_client is not None else NativeHostClient() self.signal_queue_limit = max(1, int(signal_queue_limit)) self.turn_urlopen = turn_urlopen + # The native host currently exposes frame capture and input RPCs without + # enforcing the trusted-device lease model. Keep those routes fail-closed + # at the public gateway until lease validation is wired end to end. + self.remote_control_enabled = bool(remote_control_enabled) self._signal_lock = threading.Lock() self._signals: dict[str, list[dict[str, Any]]] = {} self._last_signal_sequence: dict[str, int] = {} @@ -204,6 +209,8 @@ def handle( ) -> tuple[int, dict[str, Any]]: method = http_method.upper() try: + if not self.remote_control_enabled and not (method == "GET" and parts == ["status"]): + raise RemoteDesktopRequestError(503, "remote_desktop_disabled") return self._handle(method, parts, query, body) except RemoteDesktopHostError as exc: return ERROR_STATUS.get(exc.code, 503), {"error": exc.code} @@ -219,8 +226,10 @@ def _handle( ) -> tuple[int, dict[str, Any]]: if method == "GET" and parts == ["status"]: status = self._native_json("status", {}) - status.setdefault("iceServers", self._ice_servers()) - status.setdefault("capabilities", {})["relayAvailable"] = self._relay_available() + status.setdefault("iceServers", self._ice_servers() if self.remote_control_enabled else []) + capabilities = status.setdefault("capabilities", {}) + capabilities["relayAvailable"] = self.remote_control_enabled and self._relay_available() + capabilities["remoteControlAvailable"] = self.remote_control_enabled return 200, status if method == "GET" and parts == ["frame"]: diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index bf5ff6e0..c809603f 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -1,15 +1,18 @@ import json import sqlite3 import io +import os +import socket import subprocess import tempfile import tomllib import unittest +import urllib.request from unittest import mock from pathlib import Path import codex_phone_gateway as gateway -from codex_phone_gateway import CodexAppServerClient, GatewayState, format_stream_event +from codex_phone_gateway import CodexAppServerClient, GatewayState, format_stream_event, is_loopback_host class FakeAppServerProcess: @@ -28,6 +31,43 @@ def wait(self, timeout=None): return self.returncode +class GatewayServerHardeningTests(unittest.TestCase): + def test_accepted_connections_receive_a_read_timeout(self): + server = gateway.GatewayServer( + ("127.0.0.1", 0), + mock.Mock(), + request_timeout_seconds=2.5, + ) + client = socket.create_connection(server.server_address) + accepted = None + try: + accepted, _ = server.get_request() + self.assertEqual(accepted.gettimeout(), 2.5) + finally: + client.close() + if accepted is not None: + accepted.close() + server.server_close() + + def test_connections_over_the_concurrency_limit_are_closed(self): + server = gateway.GatewayServer( + ("127.0.0.1", 0), + mock.Mock(), + max_concurrent_requests=1, + ) + request = mock.Mock() + self.assertTrue(server._request_slots.acquire(blocking=False)) + try: + with mock.patch.object(gateway.ThreadingHTTPServer, "process_request") as process_request: + server.process_request(request, ("127.0.0.1", 12345)) + process_request.assert_not_called() + request.shutdown.assert_called_once_with(socket.SHUT_WR) + request.close.assert_called_once_with() + finally: + server._request_slots.release() + server.server_close() + + class FailingAppServerClient: def start(self): raise RuntimeError("app-server disabled for test") @@ -112,6 +152,14 @@ def complete(self, auth_payload='{"account":"new-main"}'): class AppServerClientTests(unittest.TestCase): + def test_gateway_bind_host_recognizes_only_loopback_addresses(self): + for host in ("localhost", "127.0.0.1", "127.9.8.7", "::1", "[::1]"): + with self.subTest(host=host): + self.assertTrue(is_loopback_host(host)) + for host in ("0.0.0.0", "::", "192.0.2.10", "gateway.example.com", ""): + with self.subTest(host=host): + self.assertFalse(is_loopback_host(host)) + def setUp(self): self._original_switcher_home = gateway.DEFAULT_SWITCHER_HOME self._switcher_home_tempdir = tempfile.TemporaryDirectory() @@ -137,6 +185,30 @@ def test_codex_child_env_prepends_homebrew_path_and_sets_codex_home(self): gateway.os.environ.clear() gateway.os.environ.update(old_env) + def test_codex_child_env_removes_gateway_only_credentials(self): + old_env = dict(gateway.os.environ) + try: + gateway.os.environ.clear() + gateway.os.environ.update({ + "PATH": "/usr/bin:/bin", + "CODEPILOT_TURN_API_TOKEN": "turn-secret", + "CODEPILOT_TURN_KEY_ID": "turn-key", + "CODEX_PHONE_APNS_KEY_PATH": "/private/apns-key.p8", + "CODEPILOT_FILE_DOWNLOAD_ROOTS": "/private/previews", + "SUPABASE_ACCESS_TOKEN": "connector-secret", + }) + + env = gateway.codex_child_env(Path("/tmp/codex-home")) + + self.assertNotIn("CODEPILOT_TURN_API_TOKEN", env) + self.assertNotIn("CODEPILOT_TURN_KEY_ID", env) + self.assertNotIn("CODEX_PHONE_APNS_KEY_PATH", env) + self.assertNotIn("CODEPILOT_FILE_DOWNLOAD_ROOTS", env) + self.assertNotIn("SUPABASE_ACCESS_TOKEN", env) + finally: + gateway.os.environ.clear() + gateway.os.environ.update(old_env) + def test_initializes_app_server_over_stdio(self): process = FakeAppServerProcess([ json.dumps({ @@ -527,6 +599,47 @@ def test_turn_start_sends_reasoning_effort(self): self.assertEqual(turn["method"], "turn/start") self.assertEqual(turn["params"]["effort"], "medium") + def test_turn_start_uses_safe_policy_by_default(self): + process = FakeAppServerProcess([ + json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", + json.dumps({"id": "turn-id", "result": {"turn": {"id": "turn-1"}}}) + "\n", + ]) + client = CodexAppServerClient( + codex_path=Path("/usr/local/bin/codex"), + cwd=Path("/tmp/workspace"), + process_factory=lambda args, **kwargs: process, + id_factory=iter(["init-id", "turn-id"]).__next__, + ) + + client.start() + client.turn_start("thread-1", "Hello") + sent = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + turn = sent[2] + + self.assertEqual(turn["params"]["approvalPolicy"], "on-request") + self.assertEqual(turn["params"]["sandboxPolicy"], {"type": "workspaceWrite"}) + + def test_turn_start_allows_dangerous_mode_only_when_explicitly_enabled(self): + process = FakeAppServerProcess([ + json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", + json.dumps({"id": "turn-id", "result": {"turn": {"id": "turn-1"}}}) + "\n", + ]) + client = CodexAppServerClient( + codex_path=Path("/usr/local/bin/codex"), + cwd=Path("/tmp/workspace"), + process_factory=lambda args, **kwargs: process, + id_factory=iter(["init-id", "turn-id"]).__next__, + allow_dangerous=True, + ) + + client.start() + client.turn_start("thread-1", "Hello") + sent = [json.loads(line) for line in process.stdin.getvalue().splitlines()] + turn = sent[2] + + self.assertEqual(turn["params"]["approvalPolicy"], "never") + self.assertEqual(turn["params"]["sandboxPolicy"], {"type": "dangerFullAccess"}) + def test_thread_start_maps_minimal_reasoning_effort_to_medium(self): process = FakeAppServerProcess([ json.dumps({"id": "init-id", "result": {"userAgent": "ua", "codexHome": "/tmp/codex", "platformFamily": "unix", "platformOs": "macos"}}) + "\n", @@ -896,6 +1009,230 @@ def tearDown(self): gateway.DEFAULT_SWITCHER_HOME = self._original_switcher_home self._switcher_home_tempdir.cleanup() + def test_gateway_token_file_permissions_are_repaired(self): + token_path = gateway.DEFAULT_SWITCHER_HOME / "gateway-token" + token = "a" * gateway.MIN_GATEWAY_TOKEN_LENGTH + token_path.write_text(token + "\n", encoding="utf-8") + token_path.chmod(0o644) + + self.assertEqual(gateway.read_or_create_token(token_path), token) + self.assertEqual(token_path.stat().st_mode & 0o777, 0o600) + + def test_gateway_token_rejects_weak_or_malformed_existing_values(self): + token_path = gateway.DEFAULT_SWITCHER_HOME / "gateway-token" + + for token in ("short", "a" * gateway.MIN_GATEWAY_TOKEN_LENGTH + "+"): + with self.subTest(token=token): + token_path.write_text(token + "\n", encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "at least 32 URL-safe characters"): + gateway.read_or_create_token(token_path) + + def test_gateway_token_rejects_symlink(self): + target = gateway.DEFAULT_SWITCHER_HOME / "target" + target.write_text("token\n", encoding="utf-8") + token_path = gateway.DEFAULT_SWITCHER_HOME / "gateway-token" + token_path.symlink_to(target) + + with self.assertRaisesRegex(RuntimeError, "regular file"): + gateway.read_or_create_token(token_path) + + def test_saved_attachments_are_private(self): + original_uploads_dir = gateway.DEFAULT_UPLOADS_DIR + gateway.DEFAULT_UPLOADS_DIR = gateway.DEFAULT_SWITCHER_HOME / "uploads" + try: + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + saved = state.save_attachments("thread", "job", [{ + "filename": "notes.txt", + "mimeType": "text/plain", + "dataBase64": "c2VjcmV0", + }]) + finally: + gateway.DEFAULT_UPLOADS_DIR = original_uploads_dir + + path = saved[0]["path"] + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) + self.assertEqual(path.parent.parent.stat().st_mode & 0o777, 0o700) + + def test_expired_upload_batches_are_removed_without_following_symlinks(self): + uploads = gateway.DEFAULT_SWITCHER_HOME / "uploads" + expired = uploads / "thread-a" / "expired-job" + current = uploads / "thread-a" / "current-job" + outside = gateway.DEFAULT_SWITCHER_HOME / "outside" + expired.mkdir(parents=True) + current.mkdir(parents=True) + outside.mkdir() + (expired / "private.txt").write_text("expired", encoding="utf-8") + (current / "private.txt").write_text("current", encoding="utf-8") + (uploads / "thread-a" / "linked-job").symlink_to(outside, target_is_directory=True) + os.utime(expired, (100, 100)) + os.utime(current, (900, 900)) + + removed = gateway.cleanup_expired_uploads( + uploads, + now=1_000, + retention_seconds=500, + ) + + self.assertEqual(removed, 1) + self.assertFalse(expired.exists()) + self.assertTrue((current / "private.txt").exists()) + self.assertTrue(outside.exists()) + self.assertTrue((uploads / "thread-a" / "linked-job").is_symlink()) + + def test_upload_cleanup_refuses_symlinked_root(self): + outside = gateway.DEFAULT_SWITCHER_HOME / "outside" + outside.mkdir() + private_file = outside / "private.txt" + private_file.write_text("keep", encoding="utf-8") + uploads = gateway.DEFAULT_SWITCHER_HOME / "uploads" + uploads.symlink_to(outside, target_is_directory=True) + + removed = gateway.cleanup_expired_uploads( + uploads, + now=1_000, + retention_seconds=1, + ) + + self.assertEqual(removed, 0) + self.assertTrue(private_file.exists()) + + def test_saved_attachments_reject_symlinked_upload_root(self): + original_uploads_dir = gateway.DEFAULT_UPLOADS_DIR + outside = gateway.DEFAULT_SWITCHER_HOME / "outside" + outside.mkdir() + gateway.DEFAULT_UPLOADS_DIR = gateway.DEFAULT_SWITCHER_HOME / "uploads" + gateway.DEFAULT_UPLOADS_DIR.symlink_to(outside, target_is_directory=True) + try: + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + with self.assertRaisesRegex(RuntimeError, "real directory"): + state.save_attachments("thread", "job", [{ + "filename": "notes.txt", + "mimeType": "text/plain", + "dataBase64": "c2VjcmV0", + }]) + finally: + gateway.DEFAULT_UPLOADS_DIR = original_uploads_dir + + self.assertEqual(list(outside.iterdir()), []) + + def test_rejected_attachment_batch_removes_partial_private_files(self): + original_uploads_dir = gateway.DEFAULT_UPLOADS_DIR + gateway.DEFAULT_UPLOADS_DIR = gateway.DEFAULT_SWITCHER_HOME / "uploads" + try: + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + with self.assertRaisesRegex(ValueError, "valid base64"): + state.save_attachments("thread", "job", [ + { + "filename": "private.txt", + "mimeType": "text/plain", + "dataBase64": "c2VjcmV0", + }, + { + "filename": "invalid.txt", + "mimeType": "text/plain", + "dataBase64": "not-base64!", + }, + ]) + finally: + gateway.DEFAULT_UPLOADS_DIR = original_uploads_dir + + self.assertEqual(list((gateway.DEFAULT_SWITCHER_HOME / "uploads").rglob("*")), []) + + def test_cached_thread_messages_are_private(self): + original_cache_dir = gateway.DEFAULT_THREAD_MESSAGE_CACHE_DIR + gateway.DEFAULT_THREAD_MESSAGE_CACHE_DIR = gateway.DEFAULT_SWITCHER_HOME / "message-cache" + gateway.DEFAULT_THREAD_MESSAGE_CACHE_DIR.mkdir(mode=0o755) + try: + gateway.cache_thread_message("thread", "message", "private response") + cache_path = gateway.message_cache_path("thread") + cached = gateway.read_cached_thread_messages("thread") + finally: + gateway.DEFAULT_THREAD_MESSAGE_CACHE_DIR = original_cache_dir + + self.assertEqual(cache_path.stat().st_mode & 0o777, 0o600) + self.assertEqual(cache_path.parent.stat().st_mode & 0o777, 0o700) + self.assertEqual(cached[0]["text"], "private response") + + def test_gateway_logs_redact_queries_and_local_web_capabilities(self): + handler = object.__new__(gateway.Handler) + handler.command = "GET" + handler.path = "/api/local-web/private-capability/dashboard?path=/private/file" + handler.request_version = "HTTP/1.1" + + with mock.patch.object(handler, "log_message") as log_message: + handler.log_request(200, 42) + + format_string, *arguments = log_message.call_args.args + rendered = format_string % tuple(arguments) + self.assertIn("/api/local-web/[redacted]", rendered) + self.assertNotIn("private-capability", rendered) + self.assertNotIn("/private/file", rendered) + + def test_gateway_logs_omit_file_path_queries(self): + handler = object.__new__(gateway.Handler) + handler.command = "GET" + handler.path = "/api/files/download?path=/private/file" + handler.request_version = "HTTP/1.1" + + with mock.patch.object(handler, "log_message") as log_message: + handler.log_request(200, 42) + + format_string, *arguments = log_message.call_args.args + rendered = format_string % tuple(arguments) + self.assertIn("/api/files/download", rendered) + self.assertNotIn("/private/file", rendered) + + def test_gateway_logs_escape_control_characters(self): + handler = object.__new__(gateway.Handler) + handler.client_address = ("127.0.0.1", 12345) + + with mock.patch("builtins.print") as print_mock: + handler.log_message("request %s", "safe\nforged") + + self.assertEqual(print_mock.call_args.args[0], r"127.0.0.1 - request safe\x0aforged") + + def test_exec_fallback_uses_private_temporary_output_and_removes_it(self): + captured = {} + + class Process: + stdin = io.StringIO() + stdout = [] + + @staticmethod + def wait(): + return 0 + + def process_factory(arguments, **_kwargs): + output_path = Path(arguments[arguments.index("-o") + 1]) + captured["path"] = output_path + captured["mode"] = output_path.stat().st_mode & 0o777 + output_path.write_text("Finished privately", encoding="utf-8") + return Process() + + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + job_id = "private-output-test" + gateway.JOBS[job_id] = { + "id": job_id, + "threadId": "thread-id", + "status": "running", + "events": [], + } + try: + with mock.patch.object(gateway.subprocess, "Popen", side_effect=process_factory): + state.run_turn_exec( + job_id, + {"id": "thread-id", "cwd": "/tmp"}, + "prompt", + [], + ) + + self.assertEqual(captured["mode"], 0o600) + self.assertFalse(captured["path"].exists()) + self.assertEqual(gateway.JOBS[job_id]["lastMessage"], "Finished privately") + finally: + gateway.JOBS.pop(job_id, None) + def test_public_health_exposes_safe_gateway_status(self): with tempfile.TemporaryDirectory() as tmp: original_switcher_home = gateway.DEFAULT_SWITCHER_HOME @@ -931,12 +1268,38 @@ def test_error_payload_has_stable_code_message_and_recovery(self): self.assertEqual(payload["error"]["message"], "Only localhost URLs can be opened.") self.assertIn("localhost", payload["error"]["recovery"]) + def test_decode_body_rejects_oversized_json_by_default(self): + class Handler: + headers = {"content-length": str(gateway.MAX_JSON_BODY_BYTES + 1)} + rfile = io.BytesIO(b"") + + with self.assertRaises(gateway.RequestBodyTooLarge): + gateway.decode_body(Handler()) + + def test_decode_body_rejects_invalid_content_length(self): + class Handler: + headers = {"content-length": "invalid"} + rfile = io.BytesIO(b"") + + with self.assertRaisesRegex(ValueError, "invalid_content_length"): + gateway.decode_body(Handler()) + + def test_decode_body_requires_json_object(self): + body = b"[]" + + class Handler: + headers = {"content-length": str(len(body))} + rfile = io.BytesIO(body) + + with self.assertRaisesRegex(ValueError, "request_body_must_be_an_object"): + gateway.decode_body(Handler()) + def test_resolve_requested_file_path_accepts_absolute_file_paths(self): with tempfile.TemporaryDirectory() as tmp: file_path = Path(tmp) / "created-by-codex.txt" file_path.write_text("hello", encoding="utf-8") - resolved = gateway.resolve_requested_file_path(f"{file_path}:12") + resolved = gateway.resolve_requested_file_path(f"{file_path}:12", allowed_roots=[Path(tmp)]) self.assertEqual(resolved, file_path.resolve()) @@ -944,6 +1307,14 @@ def test_resolve_requested_file_path_rejects_relative_paths(self): with self.assertRaises(ValueError): gateway.resolve_requested_file_path("created-by-codex.txt") + def test_resolve_requested_file_path_rejects_files_outside_allowed_roots(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as other: + file_path = Path(other) / "private.txt" + file_path.write_text("private", encoding="utf-8") + + with self.assertRaisesRegex(PermissionError, "download roots"): + gateway.resolve_requested_file_path(str(file_path), allowed_roots=[Path(tmp)]) + def test_file_metadata_reports_download_details(self): with tempfile.TemporaryDirectory() as tmp: file_path = Path(tmp) / "created-by-codex.txt" @@ -970,6 +1341,45 @@ def test_local_web_session_returns_gateway_path_for_localhost_url(self): self.assertEqual(session["targetOrigin"], "http://127.0.0.1:3000") self.assertRegex(session["path"], r"^/api/local-web/[^/]+/dashboard\?tab=logs$") self.assertGreater(session["expiresAt"], 0) + self.assertLessEqual( + session["expiresAt"] - int(gateway.time.time()), + gateway.LOCAL_WEB_SESSION_TIMEOUT_SECONDS, + ) + + def test_local_web_session_evicts_oldest_capability_at_limit(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + + sessions = [ + state.start_local_web_session("http://localhost:3000/") + for _ in range(gateway.LOCAL_WEB_MAX_ACTIVE_SESSIONS + 1) + ] + + with self.assertRaises(LookupError): + state.proxy_local_web_session(sessions[0]["sessionId"], [], "") + self.assertEqual(len(state._local_web_sessions), gateway.LOCAL_WEB_MAX_ACTIVE_SESSIONS) + + def test_local_web_session_expires_after_request_limit(self): + fetcher = RecordingLocalWebFetcher() + state = GatewayState( + Path("/tmp/codex"), + "token", + Path("/missing-codex"), + False, + local_web_fetcher=fetcher, + ) + session = state.start_local_web_session("http://localhost:3000/") + session_id = session["sessionId"] + state._local_web_sessions[session_id]["requestCount"] = gateway.LOCAL_WEB_MAX_REQUESTS_PER_SESSION + + with self.assertRaises(LookupError): + state.proxy_local_web_session(session_id, [], "") + + def test_local_web_redirect_cannot_leave_selected_loopback_origin(self): + handler = gateway.LoopbackOnlyRedirectHandler() + request = urllib.request.Request("http://127.0.0.1:3000/") + + with self.assertRaisesRegex(RuntimeError, "selected loopback origin"): + handler.redirect_request(request, None, 302, "Found", {}, "https://example.com/") def test_local_web_session_proxies_localhost_content_and_rewrites_same_origin_links(self): fetcher = RecordingLocalWebFetcher() @@ -1673,9 +2083,15 @@ def test_apns_live_activity_uses_liveactivity_headers_and_event_payload(self): }) command = run.call_args.args[0] - payload = json.loads(run.call_args.kwargs["input"]) - self.assertIn("apns-push-type: liveactivity", command) - self.assertIn("apns-topic: io.codepilot.iOS.push-type.liveactivity", command) + config = run.call_args.kwargs["input"].decode("utf-8") + self.assertNotIn("jwt-token", command) + self.assertNotIn("abc123", command) + self.assertIn('header = "authorization: bearer jwt-token"', config) + self.assertIn('header = "apns-push-type: liveactivity"', config) + self.assertIn('header = "apns-topic: io.codepilot.iOS.push-type.liveactivity"', config) + payload_line = next(line for line in config.splitlines() if line.startswith('data-binary = "')) + payload_text = payload_line.removeprefix('data-binary = "').removesuffix('"') + payload = json.loads(payload_text.replace('\\"', '"').replace('\\\\', '\\')) self.assertEqual(payload["aps"]["event"], "update") self.assertEqual(payload["aps"]["content-state"]["percent"], 68) self.assertEqual(invalid, []) @@ -1723,7 +2139,8 @@ def test_app_server_turn_completion_sends_registered_push_notification(self): devices, notification = push_notifier.sent[0] self.assertEqual(devices[0]["token"], "abc123") self.assertEqual(notification["title"], "Codex finished") - self.assertIn("Main", notification["body"]) + self.assertEqual(notification["body"], "Open CodePilot to view the result.") + self.assertNotIn("Main", json.dumps(notification)) self.assertEqual(notification["jobId"], "job-1") public = gateway.public_job(gateway.JOBS["job-1"]) self.assertIsInstance(public["completionNotificationSentAt"], int) @@ -1735,6 +2152,22 @@ def test_app_server_turn_completion_sends_registered_push_notification(self): finally: gateway.DEFAULT_SWITCHER_HOME = old_home + def test_failure_push_excludes_thread_title_and_error_details(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + notification = state.turn_completion_notification({ + "id": "job-1", + "threadId": "thread-a", + "threadTitle": "Private project", + "status": "failed", + "error": "credential rejected at a private path", + }) + + self.assertEqual(notification["title"], "Codex failed") + self.assertEqual(notification["body"], "Open CodePilot to review the error.") + serialized = json.dumps(notification) + self.assertNotIn("Private project", serialized) + self.assertNotIn("credential rejected", serialized) + def test_completion_push_without_registered_device_does_not_mark_sent(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) @@ -2110,6 +2543,109 @@ def fake_callback_relayer(callback_url): state.cancel_remote_account_login(started["sessionId"]) gateway.DEFAULT_SWITCHER_HOME = old_home + def test_remote_login_callback_requires_unique_authorization_state(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + callback_url = "http://localhost:1455/auth/callback?code=abc&state=expected-state" + redirect = "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback" + + for auth_url in ( + f"https://auth.openai.com/oauth/authorize?{redirect}", + f"https://auth.openai.com/oauth/authorize?{redirect}&state=first&state=second", + ): + with self.subTest(auth_url=auth_url), self.assertRaisesRegex(ValueError, "state"): + state.validated_remote_login_callback_url({"authUrl": auth_url}, callback_url) + + def test_remote_login_callback_rejects_duplicate_callback_state(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + session = { + "authUrl": ( + "https://auth.openai.com/oauth/authorize?" + "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&" + "state=expected-state" + ) + } + + with self.assertRaisesRegex(ValueError, "state"): + state.validated_remote_login_callback_url( + session, + "http://localhost:1455/auth/callback?code=abc&state=expected-state&state=other", + ) + + def test_expired_remote_login_session_terminates_process_and_removes_temp_home(self): + with tempfile.TemporaryDirectory() as tmp: + temp_home = Path(tmp) / "remote-login" + temp_home.mkdir() + process = FakeRemoteLoginProcess(temp_home, "https://auth.openai.com/oauth/authorize?state=test") + state = GatewayState(Path(tmp), "token", Path("/missing-codex"), False) + state._remote_login_sessions["expired"] = { + "id": "expired", + "createdAt": 100, + "tempCodexHome": temp_home, + "process": process, + } + + removed = state.cleanup_expired_remote_login_sessions( + now=100 + gateway.REMOTE_LOGIN_SESSION_TIMEOUT_SECONDS, + ) + + self.assertEqual(removed, 1) + self.assertTrue(process.terminated) + self.assertFalse(temp_home.exists()) + self.assertEqual(state._remote_login_sessions, {}) + + def test_remote_login_capacity_is_bounded(self): + state = GatewayState(Path("/tmp/codex"), "token", Path("/missing-codex"), False) + state._remote_login_sessions = { + f"session-{index}": {"createdAt": int(gateway.time.time())} + for index in range(gateway.REMOTE_LOGIN_MAX_ACTIVE_SESSIONS) + } + + with self.assertRaisesRegex(ValueError, "Too many remote login sessions"): + state.ensure_remote_login_capacity() + + def test_remote_login_callback_failure_cleans_up_session(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + switcher_home = tmp_path / "switcher" + codex_home = tmp_path / "codex" + codex_home.mkdir() + (switcher_home / "accounts" / "Main").mkdir(parents=True) + (switcher_home / "accounts" / "Main" / "auth.json").write_text("{}", encoding="utf-8") + auth_url = ( + "https://auth.openai.com/oauth/authorize?" + "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&" + "state=expected-state" + ) + processes = [] + + def fake_login_process_factory(args, **kwargs): + process = FakeRemoteLoginProcess(Path(kwargs["env"]["CODEX_HOME"]), auth_url) + processes.append(process) + return process + + old_home = gateway.DEFAULT_SWITCHER_HOME + gateway.DEFAULT_SWITCHER_HOME = switcher_home + try: + state = GatewayState( + codex_home, + "token", + Path("/missing-codex"), + False, + login_process_factory=fake_login_process_factory, + ) + started = state.start_remote_account_login("Main") + + with self.assertRaisesRegex(ValueError, "state"): + state.complete_remote_account_login( + started["sessionId"], + "http://localhost:1455/auth/callback?code=abc&state=wrong-state", + ) + + self.assertTrue(processes[0].terminated) + self.assertEqual(state._remote_login_sessions, {}) + finally: + gateway.DEFAULT_SWITCHER_HOME = old_home + def test_remote_new_account_login_saves_profile_without_switching(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) diff --git a/gateway/test_remote_desktop_gateway.py b/gateway/test_remote_desktop_gateway.py index af2e460b..a45d3563 100644 --- a/gateway/test_remote_desktop_gateway.py +++ b/gateway/test_remote_desktop_gateway.py @@ -213,10 +213,30 @@ def call(self, method, payload=None): class RemoteDesktopGatewayTests(unittest.TestCase): - def test_rejects_unknown_body_keys_and_malformed_identifiers(self): + def test_remote_control_routes_fail_closed_by_default(self): host = FakeNativeHostClient() gateway = RemoteDesktopGateway(host_client=host) + status, payload = gateway.handle("GET", ["frame"], {}, None) + + self.assertEqual(status, 503) + self.assertEqual(payload["error"], "remote_desktop_disabled") + self.assertEqual(host.calls, []) + + def test_status_reports_remote_control_unavailable_by_default(self): + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + + status, payload = gateway.handle("GET", ["status"], {}, None) + + self.assertEqual(status, 200) + self.assertFalse(payload["capabilities"]["remoteControlAvailable"]) + self.assertFalse(payload["capabilities"]["relayAvailable"]) + self.assertEqual(payload["iceServers"], []) + + def test_rejects_unknown_body_keys_and_malformed_identifiers(self): + host = FakeNativeHostClient() + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) + status, payload = gateway.handle("POST", ["pairing", "start"], {}, {"deviceId": "phone-1", "extra": True}) self.assertEqual(status, 400) self.assertEqual(payload["error"], "invalid_request") @@ -229,7 +249,7 @@ def test_rejects_unknown_body_keys_and_malformed_identifiers(self): def test_maps_native_errors_to_stable_public_statuses(self): host = FakeNativeHostClient() host.fail_code = "controller_busy" - gateway = RemoteDesktopGateway(host_client=host) + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) status, payload = gateway.handle("POST", ["sessions"], {}, { "deviceId": "phone-1", @@ -243,7 +263,7 @@ def test_maps_native_errors_to_stable_public_statuses(self): def test_pairing_and_session_routes_forward_validated_payloads(self): host = FakeNativeHostClient() - gateway = RemoteDesktopGateway(host_client=host) + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) status, payload = gateway.handle("POST", ["pairing", "complete"], {}, { "challengeId": "challenge-1", @@ -257,7 +277,7 @@ def test_pairing_and_session_routes_forward_validated_payloads(self): self.assertEqual(host.calls[0][1]["signature"], base64.b64encode(b"proof").decode("ascii")) def test_rejects_invalid_signature_base64(self): - gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), remote_control_enabled=True) status, payload = gateway.handle("POST", ["sessions"], {}, { "deviceId": "phone-1", @@ -269,7 +289,11 @@ def test_rejects_invalid_signature_base64(self): self.assertEqual(payload["error"], "invalid_base64") def test_signal_queue_is_bounded_monotonic_and_cleared_on_disconnect(self): - gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), signal_queue_limit=2) + gateway = RemoteDesktopGateway( + host_client=FakeNativeHostClient(), + signal_queue_limit=2, + remote_control_enabled=True, + ) for sequence in [1, 2, 3]: status, payload = gateway.handle("POST", ["sessions", "session-1", "signal"], {}, { @@ -299,7 +323,7 @@ def test_signal_queue_is_bounded_monotonic_and_cleared_on_disconnect(self): def test_clipboard_requires_explicit_text_under_size_limit(self): host = FakeNativeHostClient() - gateway = RemoteDesktopGateway(host_client=host) + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) status, payload = gateway.handle("POST", ["sessions", "session-1", "clipboard"], {}, { "direction": "send", @@ -327,7 +351,7 @@ def test_status_returns_stun_only_when_turn_environment_is_unset(self): try: os.environ.pop("CODEPILOT_TURN_KEY_ID", None) os.environ.pop("CODEPILOT_TURN_API_TOKEN", None) - gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), remote_control_enabled=True) status, payload = gateway.handle("GET", ["status"], {}, None) @@ -345,7 +369,7 @@ def test_frame_returns_raw_jpeg_payload(self): "payload": b"\xff\xd8jpeg\xff\xd9", "errorCode": None, } - gateway = RemoteDesktopGateway(host_client=host) + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) status, payload = gateway.handle("GET", ["frame"], {}, None) @@ -355,7 +379,7 @@ def test_frame_returns_raw_jpeg_payload(self): def test_input_forwards_validated_event_to_native_host(self): host = FakeNativeHostClient() - gateway = RemoteDesktopGateway(host_client=host) + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) event = { "sessionId": "gateway-session", "sequence": 7, @@ -377,7 +401,7 @@ def test_input_forwards_validated_event_to_native_host(self): def test_input_accepts_swift_payload_with_omitted_optional_fields(self): host = FakeNativeHostClient() - gateway = RemoteDesktopGateway(host_client=host) + gateway = RemoteDesktopGateway(host_client=host, remote_control_enabled=True) event = { "sessionId": "swift-session", "sequence": 1, @@ -393,7 +417,7 @@ def test_input_accepts_swift_payload_with_omitted_optional_fields(self): self.assertEqual(host.calls[-1], ("input.inject", event)) def test_input_rejects_unknown_fields(self): - gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient()) + gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), remote_control_enabled=True) status, payload = gateway.handle("POST", ["input"], {}, { "sessionId": "gateway-session", @@ -442,7 +466,11 @@ def fake_urlopen(request, timeout): try: os.environ["CODEPILOT_TURN_KEY_ID"] = "turn-key-id" os.environ["CODEPILOT_TURN_API_TOKEN"] = "secret-token" - gateway = RemoteDesktopGateway(host_client=FakeNativeHostClient(), turn_urlopen=fake_urlopen) + gateway = RemoteDesktopGateway( + host_client=FakeNativeHostClient(), + turn_urlopen=fake_urlopen, + remote_control_enabled=True, + ) status, payload = gateway.handle("GET", ["status"], {}, None) diff --git a/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj b/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj index b02ceeb7..0b10e176 100644 --- a/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj +++ b/ios/CodexPhone/CodexPhone.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 8F4E51112F42100100C0D3C1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F4E51102F42100100C0D3C1 /* Assets.xcassets */; }; 8F4E51212F42100100C0D3C1 /* ChatAutoScrollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E51202F42100100C0D3C1 /* ChatAutoScrollState.swift */; }; + 8F4E54312F49000100C0D3C1 /* GatewayCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E54302F49000100C0D3C1 /* GatewayCredentials.swift */; }; 8F4E51002F41000100C0D3C1 /* CodexPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E50FF2F41000100C0D3C1 /* CodexPhoneApp.swift */; }; 8F4E52012F43000100C0D3C1 /* RemoteDesktopModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E52002F43000100C0D3C1 /* RemoteDesktopModels.swift */; }; 8F4E52032F43000100C0D3C1 /* RemoteDesktopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4E52022F43000100C0D3C1 /* RemoteDesktopTests.swift */; }; @@ -65,6 +66,7 @@ /* Begin PBXFileReference section */ 8F4E51202F42100100C0D3C1 /* ChatAutoScrollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAutoScrollState.swift; sourceTree = ""; }; + 8F4E54302F49000100C0D3C1 /* GatewayCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayCredentials.swift; sourceTree = ""; }; 8F4E51222F42100100C0D3C1 /* CodexPhone.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodexPhone.entitlements; sourceTree = ""; }; 8F4E50FF2F41000100C0D3C1 /* CodexPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexPhoneApp.swift; sourceTree = ""; }; 8F4E51012F41000100C0D3C1 /* CodexPhone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodexPhone.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -142,6 +144,7 @@ isa = PBXGroup; children = ( 8F4E50FF2F41000100C0D3C1 /* CodexPhoneApp.swift */, + 8F4E54302F49000100C0D3C1 /* GatewayCredentials.swift */, 8F4E54022F47000100C0D3C1 /* TotalCreditActivityAttributes.swift */, 8F4E54042F47000100C0D3C1 /* TotalCreditStatus.swift */, 8F4E54062F47000100C0D3C1 /* TotalCreditActivityController.swift */, @@ -325,6 +328,7 @@ 8F4E54052F47000100C0D3C1 /* TotalCreditStatus.swift in Sources */, 8F4E54072F47000100C0D3C1 /* TotalCreditActivityController.swift in Sources */, 8F4E51212F42100100C0D3C1 /* ChatAutoScrollState.swift in Sources */, + 8F4E54312F49000100C0D3C1 /* GatewayCredentials.swift in Sources */, 8F4E51002F41000100C0D3C1 /* CodexPhoneApp.swift in Sources */, 8F4E53032F44000100C0D3C1 /* RemoteDesktopAPI.swift in Sources */, 8F4E53012F44000100C0D3C1 /* RemoteDeviceIdentity.swift in Sources */, diff --git a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift index fd7e47b4..5cd14330 100644 --- a/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift +++ b/ios/CodexPhone/CodexPhone/CodexPhoneApp.swift @@ -31,8 +31,6 @@ struct CodexPhoneApp: App { switch arguments[index] { case "--gateway-url" where arguments.indices.contains(index + 1): UserDefaults.standard.set(arguments[index + 1], forKey: "gatewayURL") - case "--gateway-token" where arguments.indices.contains(index + 1): - UserDefaults.standard.set(arguments[index + 1], forKey: "gatewayToken") default: break } @@ -73,9 +71,9 @@ final class CodexPhoneAppDelegate: NSObject, UIApplicationDelegate, UNUserNotifi private func sendDeviceTokenToGateway(_ deviceToken: Data) { let gatewayURL = UserDefaults.standard.string(forKey: "gatewayURL") ?? "" - let gatewayToken = UserDefaults.standard.string(forKey: "gatewayToken") ?? "" + let gatewayToken = SecureGatewayTokenStore.read() guard !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let root = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + let root = GatewayEndpoint.baseURL(from: gatewayURL), let url = URL(string: "/api/notifications/device", relativeTo: root)?.absoluteURL else { return } @@ -95,7 +93,7 @@ final class CodexPhoneAppDelegate: NSObject, UIApplicationDelegate, UNUserNotifi request.setValue("Bearer \(gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines))", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONEncoder().encode(body) - URLSession.shared.dataTask(with: request).resume() + GatewayURLSession.shared.dataTask(with: request).resume() } private func apnsEnvironment() -> String { @@ -113,9 +111,8 @@ final class CodexPhoneAppDelegate: NSObject, UIApplicationDelegate, UNUserNotifi struct RootView: View { @StateObject private var model = CodexPhoneModel() + @StateObject private var credentials = GatewayCredentials() @Environment(\.scenePhase) private var scenePhase - @AppStorage("gatewayURL") private var gatewayURL = "" - @AppStorage("gatewayToken") private var gatewayToken = "" @AppStorage("totalCreditLiveActivityEnabled") private var totalCreditLiveActivityEnabled = false @State private var showingSettings = false @State private var showingStatus = false @@ -129,11 +126,11 @@ struct RootView: View { NavigationStack { Group { if gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - EmptySettingsView(gatewayURL: $gatewayURL, gatewayToken: $gatewayToken) + EmptySettingsView(gatewayURL: gatewayURLBinding, gatewayToken: gatewayTokenBinding) } else if model.threads.isEmpty && model.isLoading { ProgressView("Loading") } else { - ThreadListView(model: model) { + ThreadListView(model: model, gatewayURL: gatewayURL, gatewayToken: gatewayToken) { showingRemoteDesktop = true } } @@ -211,8 +208,8 @@ struct RootView: View { } .sheet(isPresented: $showingSettings) { SettingsView( - gatewayURL: $gatewayURL, - gatewayToken: $gatewayToken, + gatewayURL: gatewayURLBinding, + gatewayToken: gatewayTokenBinding, model: model, liveActivityError: $liveActivityError ) @@ -222,7 +219,7 @@ struct RootView: View { AccountStatusView(model: model, gatewayURL: gatewayURL, gatewayToken: gatewayToken) } .fullScreenCover(isPresented: $showingRemoteDesktop) { - RemotePairingView() + RemotePairingView(gatewayURL: gatewayURL, gatewayToken: gatewayToken) } .sheet(isPresented: $showingAccountSwitcher) { AccountSwitcherView(model: model, gatewayURL: gatewayURL, gatewayToken: gatewayToken) @@ -240,7 +237,7 @@ struct RootView: View { .presentationDetents([.medium, .large]) } .navigationDestination(item: $openedThread) { thread in - ChatView(model: model, thread: thread) + ChatView(model: model, thread: thread, gatewayURL: gatewayURL, gatewayToken: gatewayToken) } .alert("CodePilot", isPresented: .constant(model.errorMessage != nil)) { Button("OK") { model.errorMessage = nil } @@ -266,6 +263,28 @@ struct RootView: View { } } + private var gatewayURL: String { + credentials.gatewayURL + } + + private var gatewayToken: String { + credentials.gatewayToken + } + + private var gatewayURLBinding: Binding { + Binding( + get: { credentials.gatewayURL }, + set: { credentials.gatewayURL = $0 } + ) + } + + private var gatewayTokenBinding: Binding { + Binding( + get: { credentials.gatewayToken }, + set: { credentials.updateGatewayToken($0) } + ) + } + private var liveActivityReconciliationID: String { "\(totalCreditLiveActivityEnabled)-\(model.accountStatusGeneratedAt ?? 0)-\(scenePhase == .active)" } @@ -584,9 +603,9 @@ struct ReasoningLevelPicker: View { struct ThreadListView: View { @ObservedObject var model: CodexPhoneModel + let gatewayURL: String + let gatewayToken: String var onRemoteDesktop: () -> Void = {} - @AppStorage("gatewayURL") private var gatewayURL = "" - @AppStorage("gatewayToken") private var gatewayToken = "" @State private var renamingThread: CodexThread? @State private var renameText = "" @State private var deletingThread: CodexThread? @@ -694,7 +713,7 @@ struct ThreadListView: View { private func threadRow(_ thread: CodexThread) -> some View { NavigationLink { - ChatView(model: model, thread: thread) + ChatView(model: model, thread: thread, gatewayURL: gatewayURL, gatewayToken: gatewayToken) .id(thread.id) } label: { VStack(alignment: .leading, spacing: 6) { @@ -947,9 +966,9 @@ struct NewThreadView: View { struct ChatView: View { @ObservedObject var model: CodexPhoneModel let thread: CodexThread + let gatewayURL: String + let gatewayToken: String @Environment(\.scenePhase) private var scenePhase - @AppStorage("gatewayURL") private var gatewayURL = "" - @AppStorage("gatewayToken") private var gatewayToken = "" @State private var prompt = "" @State private var attachments: [PendingAttachment] = [] @State private var showingFileImporter = false @@ -1799,7 +1818,7 @@ func isMacLocalWebURL(_ url: URL) -> Bool { } func localWebSessionURL(path: String, baseURL: String) -> URL? { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let root = GatewayEndpoint.baseURL(from: baseURL), var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { return nil } @@ -3607,13 +3626,18 @@ final class RemoteLoginAuthenticationViewController: UIViewController, ASWebAuth guard let redirectURL = Self.redirectURL(from: url), let port = redirectURL.port, + let expectedState = Self.state(from: url), Self.isCodexLoopbackCallback(redirectURL) else { onFailure("Login callback URL could not be prepared.") return } do { - let server = try LoopbackCallbackServer(port: UInt16(port), expectedPath: redirectURL.path) { [weak self] callbackURL in + let server = try LoopbackCallbackServer( + port: UInt16(port), + expectedPath: redirectURL.path, + expectedState: expectedState + ) { [weak self] callbackURL in DispatchQueue.main.async { guard let self, !self.didComplete else { return } self.didComplete = true @@ -3673,6 +3697,14 @@ final class RemoteLoginAuthenticationViewController: UIViewController, ASWebAuth .flatMap(URL.init(string:)) } + private static func state(from authURL: URL) -> String? { + URLComponents(url: authURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "state" }? + .value + .flatMap { $0.isEmpty ? nil : $0 } + } + private static func isCodexLoopbackCallback(_ url: URL) -> Bool { guard let host = url.host?.lowercased(), host == "localhost" || host == "127.0.0.1", @@ -3686,17 +3718,26 @@ final class RemoteLoginAuthenticationViewController: UIViewController, ASWebAuth final class LoopbackCallbackServer { private let port: UInt16 private let expectedPath: String + private let expectedState: String private let onCallback: (URL) -> Void private let queue = DispatchQueue(label: "codexphone.loopback-callback") private var listener: NWListener? private var didComplete = false - init(port: UInt16, expectedPath: String, onCallback: @escaping (URL) -> Void) throws { + init( + port: UInt16, + expectedPath: String, + expectedState: String, + onCallback: @escaping (URL) -> Void + ) throws { self.port = port self.expectedPath = expectedPath.isEmpty ? "/auth/callback" : expectedPath + self.expectedState = expectedState self.onCallback = onCallback let nwPort = NWEndpoint.Port(rawValue: port)! - listener = try NWListener(using: .tcp, on: nwPort) + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: nwPort) + listener = try NWListener(using: parameters) listener?.newConnectionHandler = { [weak self] connection in self?.handle(connection) } @@ -3720,7 +3761,12 @@ final class LoopbackCallbackServer { } guard let data, let request = String(data: data, encoding: .utf8), - let callbackURL = self.callbackURL(from: request) else { + let callbackURL = loopbackCallbackURL( + from: request, + port: self.port, + expectedPath: self.expectedPath, + expectedState: self.expectedState + ) else { self.respond(to: connection, status: "400 Bad Request", body: "Invalid Codex callback.") return } @@ -3731,17 +3777,6 @@ final class LoopbackCallbackServer { } } - private func callbackURL(from request: String) -> URL? { - guard let requestLine = request.split(separator: "\r\n", maxSplits: 1, omittingEmptySubsequences: false).first else { - return nil - } - let parts = requestLine.split(separator: " ") - guard parts.count >= 2 else { return nil } - let path = String(parts[1]) - guard path.hasPrefix(expectedPath) else { return nil } - return URL(string: "http://localhost:\(port)\(path)") - } - private func respond(to connection: NWConnection, status: String, body: String) { let response = """ HTTP/1.1 \(status)\r @@ -3757,6 +3792,28 @@ final class LoopbackCallbackServer { } } +func loopbackCallbackURL( + from request: String, + port: UInt16, + expectedPath: String, + expectedState: String +) -> URL? { + guard let requestLine = request.split(separator: "\r\n", maxSplits: 1, omittingEmptySubsequences: false).first else { + return nil + } + let parts = requestLine.split(separator: " ") + guard parts.count == 3, parts[0] == "GET" else { return nil } + let requestTarget = String(parts[1]) + guard requestTarget.hasPrefix("/"), + let callbackURL = URL(string: "http://127.0.0.1:\(port)\(requestTarget)"), + let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + components.path == expectedPath, + components.queryItems?.filter({ $0.name == "state" }).map(\.value) == [expectedState] else { + return nil + } + return callbackURL +} + @MainActor final class CodexPhoneModel: ObservableObject { @Published var threads: [CodexThread] = [] @@ -4822,7 +4879,7 @@ struct CodexGatewayClient { } func downloadRemoteFile(path: String, baseURL: String, token: String) async throws -> URL { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { + guard let root = GatewayEndpoint.baseURL(from: baseURL) else { throw GatewayError.invalidURL } guard var components = URLComponents(url: root, resolvingAgainstBaseURL: false) else { @@ -4839,7 +4896,7 @@ struct CodexGatewayClient { request.timeoutInterval = 120 request.setValue("Bearer \(token.trimmingCharacters(in: .whitespacesAndNewlines))", forHTTPHeaderField: "Authorization") - let (temporaryURL, response) = try await URLSession.shared.download(for: request) + let (temporaryURL, response) = try await GatewayURLSession.shared.download(for: request) guard let http = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } @@ -4885,7 +4942,7 @@ struct CodexGatewayClient { baseURL: String, token: String ) async throws -> T { - guard let root = URL(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { + guard let root = GatewayEndpoint.baseURL(from: baseURL) else { throw GatewayError.invalidURL } guard let url = URL(string: path, relativeTo: root)?.absoluteURL else { @@ -4903,7 +4960,7 @@ struct CodexGatewayClient { var attempt = 0 while true { do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await GatewayURLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } diff --git a/ios/CodexPhone/CodexPhone/GatewayCredentials.swift b/ios/CodexPhone/CodexPhone/GatewayCredentials.swift new file mode 100644 index 00000000..de26f5ff --- /dev/null +++ b/ios/CodexPhone/CodexPhone/GatewayCredentials.swift @@ -0,0 +1,189 @@ +import Foundation +import Security +import SwiftUI + +enum GatewayEndpoint { + static func baseURL(from rawValue: String) -> URL? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + var components = URLComponents(string: value), + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + !host.isEmpty, + scheme == "https" || (scheme == "http" && isLoopbackHost(host)) else { + return nil + } + + components.scheme = scheme + components.host = host + return components.url + } + + static func hasSameOrigin(_ first: URL?, _ second: URL?) -> Bool { + guard let first, + let second, + let firstComponents = URLComponents(url: first, resolvingAgainstBaseURL: false), + let secondComponents = URLComponents(url: second, resolvingAgainstBaseURL: false), + let firstScheme = firstComponents.scheme?.lowercased(), + let secondScheme = secondComponents.scheme?.lowercased(), + let firstHost = firstComponents.host?.lowercased(), + let secondHost = secondComponents.host?.lowercased() else { + return false + } + return firstScheme == secondScheme + && firstHost == secondHost + && effectivePort(firstComponents) == effectivePort(secondComponents) + } + + private static func effectivePort(_ components: URLComponents) -> Int? { + if let port = components.port { + return port + } + switch components.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } + + private static func isLoopbackHost(_ host: String) -> Bool { + if host == "localhost" || host == "::1" { + return true + } + + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4, octets[0] == "127" else { + return false + } + return octets.allSatisfy { octet in + !octet.isEmpty + && octet.allSatisfy(\.isNumber) + && Int(octet).map { (0...255).contains($0) } == true + } + } +} + +private final class GatewayRedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard GatewayEndpoint.hasSameOrigin(task.currentRequest?.url, request.url) else { + completionHandler(nil) + return + } + completionHandler(request) + } +} + +enum GatewayURLSession { + static let shared: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: configuration, delegate: GatewayRedirectDelegate(), delegateQueue: nil) + }() +} + +enum SecureGatewayTokenStore { + private static let service = "io.codepilot.gateway" + private static let account = "gatewayToken" + private static let legacyDefaultsKey = "gatewayToken" + + static func read() -> String { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let token = String(data: data, encoding: .utf8) else { + return "" + } + return token + } + + @discardableResult + static func save(_ rawToken: String) -> Bool { + let token = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + return false + } + UserDefaults.standard.removeObject(forKey: legacyDefaultsKey) + return true + } + + let data = Data(token.utf8) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + var status = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var query = baseQuery() + query.merge(attributes) { _, newValue in newValue } + status = SecItemAdd(query as CFDictionary, nil) + } + guard status == errSecSuccess else { + return false + } + UserDefaults.standard.removeObject(forKey: legacyDefaultsKey) + return true + } + + @discardableResult + static func migrateLegacyTokenIfNeeded() -> String { + let current = read() + let legacy = UserDefaults.standard.string(forKey: legacyDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if current.isEmpty, !legacy.isEmpty { + return save(legacy) ? legacy : "" + } + if !current.isEmpty { + UserDefaults.standard.removeObject(forKey: legacyDefaultsKey) + } + return current + } + + private static func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} + +@MainActor +final class GatewayCredentials: ObservableObject { + @Published var gatewayURL: String { + didSet { + UserDefaults.standard.set(gatewayURL, forKey: "gatewayURL") + } + } + + @Published private(set) var gatewayToken: String + + init() { + gatewayURL = UserDefaults.standard.string(forKey: "gatewayURL") ?? "" + gatewayToken = SecureGatewayTokenStore.migrateLegacyTokenIfNeeded() + } + + func updateGatewayToken(_ token: String) { + let normalized = token.trimmingCharacters(in: .whitespacesAndNewlines) + if SecureGatewayTokenStore.save(normalized) { + gatewayToken = normalized + } + } +} diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift index 837b810c..ad11ffff 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopAPI.swift @@ -150,7 +150,7 @@ struct RemoteDesktopAPI { } private static func defaultTransport(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await GatewayURLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw RemoteDesktopAPIError.invalidResponse } diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift index 9b9f0a76..91e6770d 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemoteDesktopView.swift @@ -7,11 +7,11 @@ struct RemoteDesktopView: View { case pan } + let gatewayURL: String + let gatewayToken: String @Environment(\.dismiss) private var dismiss @FocusState private var keyboardFocused: Bool @StateObject private var peer = RemotePeerConnection() - @AppStorage("gatewayURL") private var gatewayURL = "" - @AppStorage("gatewayToken") private var gatewayToken = "" @State private var session = RemoteDesktopSessionState(leaseID: "preview") @State private var mapper = RemoteInputMapper(sessionID: "preview") @State private var frameImage: UIImage? @@ -208,7 +208,7 @@ struct RemoteDesktopView: View { private func startFrameLoop() { stopFrameLoop() - guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let baseURL = GatewayEndpoint.baseURL(from: gatewayURL), !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { frameError = "Gateway is not configured." return @@ -255,7 +255,7 @@ struct RemoteDesktopView: View { private func startWebRTC() { stopWebRTC() - guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let baseURL = GatewayEndpoint.baseURL(from: gatewayURL), !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) webRTCTask = Task { @@ -291,7 +291,7 @@ struct RemoteDesktopView: View { if peer.sendInput(event) { return } - guard let baseURL = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let baseURL = GatewayEndpoint.baseURL(from: gatewayURL), !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let api = RemoteDesktopAPI(baseURL: baseURL, token: gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)) let previous = inputTask diff --git a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift index facb30a3..91928bfb 100644 --- a/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift +++ b/ios/CodexPhone/CodexPhone/RemoteDesktop/RemotePairingView.swift @@ -2,9 +2,9 @@ import SwiftUI import UIKit struct RemotePairingView: View { + let gatewayURL: String + let gatewayToken: String @Environment(\.dismiss) private var dismiss - @AppStorage("gatewayURL") private var gatewayURL = "" - @AppStorage("gatewayToken") private var gatewayToken = "" @State private var statusText = "Not paired" @State private var isLoading = false @State private var identity = SoftwareRemoteDeviceIdentity(deviceID: UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString) @@ -24,7 +24,7 @@ struct RemotePairingView: View { Section { NavigationLink { - RemoteDesktopView() + RemoteDesktopView(gatewayURL: gatewayURL, gatewayToken: gatewayToken) } label: { Label("Start Session", systemImage: "play.rectangle") } @@ -113,7 +113,7 @@ struct RemotePairingView: View { } private func api() -> RemoteDesktopAPI? { - guard let url = URL(string: gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)), + guard let url = GatewayEndpoint.baseURL(from: gatewayURL), !gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } diff --git a/ios/CodexPhone/CodexPhone/TotalCreditActivityController.swift b/ios/CodexPhone/CodexPhone/TotalCreditActivityController.swift index 3c9dc95f..4f19bdea 100644 --- a/ios/CodexPhone/CodexPhone/TotalCreditActivityController.swift +++ b/ios/CodexPhone/CodexPhone/TotalCreditActivityController.swift @@ -161,7 +161,7 @@ actor LiveActivityGatewayRegistrar { private func endpoint(path: String, context: LiveActivityRegistrationContext) -> URL? { guard !context.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let root = URL(string: context.baseURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { + let root = GatewayEndpoint.baseURL(from: context.baseURL) else { return nil } return URL(string: path, relativeTo: root)?.absoluteURL @@ -180,7 +180,7 @@ actor LiveActivityGatewayRegistrar { } private func send(_ request: URLRequest) async throws { - let (_, response) = try await URLSession.shared.data(for: request) + let (_, response) = try await GatewayURLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw URLError(.badServerResponse) } diff --git a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift index 1524b736..d139be34 100644 --- a/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift +++ b/ios/CodexPhone/CodexPhoneTests/RemoteDesktopTests.swift @@ -3,6 +3,27 @@ import XCTest @testable import CodexPhone final class RemoteDesktopTests: XCTestCase { + func testLegacyGatewayTokenMigratesOutOfPreferences() { + let defaults = UserDefaults.standard + let previousToken = SecureGatewayTokenStore.read() + let previousLegacyToken = defaults.string(forKey: "gatewayToken") + defer { + SecureGatewayTokenStore.save(previousToken) + if let previousLegacyToken { + defaults.set(previousLegacyToken, forKey: "gatewayToken") + } else { + defaults.removeObject(forKey: "gatewayToken") + } + } + + XCTAssertTrue(SecureGatewayTokenStore.save("")) + defaults.set("legacy-test-token", forKey: "gatewayToken") + + XCTAssertEqual(SecureGatewayTokenStore.migrateLegacyTokenIfNeeded(), "legacy-test-token") + XCTAssertEqual(SecureGatewayTokenStore.read(), "legacy-test-token") + XCTAssertNil(defaults.object(forKey: "gatewayToken")) + } + func testGatewayErrorRecoveryCopyUsesStablePayload() { let error = GatewayErrorPayload.ErrorBody( code: "gateway_unavailable", @@ -24,6 +45,69 @@ final class RemoteDesktopTests: XCTestCase { XCTAssertTrue(GatewayConnectionKind.cloudflare.helpText.contains("Cloudflare Tunnel")) } + func testGatewayEndpointRequiresHTTPSExceptForLoopbackDevelopment() throws { + XCTAssertEqual( + GatewayEndpoint.baseURL(from: "https://gateway.example")?.absoluteString, + "https://gateway.example" + ) + XCTAssertNotNil(GatewayEndpoint.baseURL(from: "http://localhost:18790")) + XCTAssertNotNil(GatewayEndpoint.baseURL(from: "http://127.0.0.2:18790")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "http://127.example:18790")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "http://127.0.0.1.example:18790")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "http://127.0.0.256:18790")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "http://gateway.example")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "ftp://gateway.example")) + } + + func testGatewayEndpointRejectsCredentialAndFragmentAmbiguity() { + XCTAssertNil(GatewayEndpoint.baseURL(from: "https://user:password@gateway")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "https://gateway.example?target=elsewhere")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "https://gateway.example/#token")) + XCTAssertNil(GatewayEndpoint.baseURL(from: "//gateway.example")) + } + + func testLoopbackCallbackRequiresExactPathAndOAuthState() throws { + let valid = try XCTUnwrap(loopbackCallbackURL( + from: "GET /auth/callback?code=fixture&state=expected HTTP/1.1\r\nHost: localhost\r\n\r\n", + port: 1455, + expectedPath: "/auth/callback", + expectedState: "expected" + )) + + XCTAssertEqual(valid.path, "/auth/callback") + XCTAssertNil(loopbackCallbackURL( + from: "GET /auth/callback/forged?state=expected HTTP/1.1\r\n\r\n", + port: 1455, + expectedPath: "/auth/callback", + expectedState: "expected" + )) + XCTAssertNil(loopbackCallbackURL( + from: "GET /auth/callback?state=wrong HTTP/1.1\r\n\r\n", + port: 1455, + expectedPath: "/auth/callback", + expectedState: "expected" + )) + XCTAssertNil(loopbackCallbackURL( + from: "POST /auth/callback?state=expected HTTP/1.1\r\n\r\n", + port: 1455, + expectedPath: "/auth/callback", + expectedState: "expected" + )) + } + + func testGatewayOriginsIncludeSchemeHostAndEffectivePort() throws { + let standardHTTPS = try XCTUnwrap(URL(string: "https://gateway.example/api/health")) + let explicitHTTPS = try XCTUnwrap(URL(string: "https://gateway.example:443/redirect")) + let otherHost = try XCTUnwrap(URL(string: "https://other.example/redirect")) + let downgrade = try XCTUnwrap(URL(string: "http://gateway.example/redirect")) + let otherPort = try XCTUnwrap(URL(string: "https://gateway.example:8443/redirect")) + + XCTAssertTrue(GatewayEndpoint.hasSameOrigin(standardHTTPS, explicitHTTPS)) + XCTAssertFalse(GatewayEndpoint.hasSameOrigin(standardHTTPS, otherHost)) + XCTAssertFalse(GatewayEndpoint.hasSameOrigin(standardHTTPS, downgrade)) + XCTAssertFalse(GatewayEndpoint.hasSameOrigin(standardHTTPS, otherPort)) + } + func testRemotePairingApprovalStatusRoundTrips() throws { let status = RemotePairingApprovalStatus( status: "pending_mac_approval", diff --git a/ios/CodexPhone/Gemfile b/ios/CodexPhone/Gemfile index 7a118b49..6f23456e 100644 --- a/ios/CodexPhone/Gemfile +++ b/ios/CodexPhone/Gemfile @@ -1,3 +1,7 @@ source "https://rubygems.org" -gem "fastlane" +gem "fastlane", ">= 2.237.0", "< 3" +gem "excon", ">= 1.5.0", "< 2" +gem "faraday", ">= 1.10.6", "< 2" +gem "json", ">= 2.19.2", "< 3" +gem "jwt", ">= 2.10.3", "< 3" diff --git a/ios/CodexPhone/Gemfile.lock b/ios/CodexPhone/Gemfile.lock index 9615d59f..1113e628 100644 --- a/ios/CodexPhone/Gemfile.lock +++ b/ios/CodexPhone/Gemfile.lock @@ -42,8 +42,9 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.5) + excon (1.5.0) + logger + faraday (1.10.6) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -72,22 +73,22 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.1) - fastlane (2.234.0) + fastlane (2.237.0) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) - addressable (>= 2.8, < 3.0.0) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) base64 (~> 0.2) benchmark (>= 0.1.0) - bundler (>= 1.17.3, < 5.0.0) + bundler (>= 2.4.0, < 5.0.0) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) + excon (>= 0.71.0, < 2.0.0) faraday (~> 1.0) faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) @@ -96,14 +97,15 @@ GEM gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) mutex_m (~> 0.3) naturally (~> 2.2) @@ -168,8 +170,8 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) - jwt (2.10.2) + json (2.19.2) + jwt (2.10.3) base64 logger (1.7.0) mini_magick (4.13.2) @@ -232,7 +234,11 @@ PLATFORMS ruby DEPENDENCIES - fastlane + excon (>= 1.5.0, < 2) + faraday (>= 1.10.6, < 2) + fastlane (>= 2.237.0, < 3) + json (>= 2.19.2, < 3) + jwt (>= 2.10.3, < 3) CHECKSUMS CFPropertyList (3.0.8) @@ -260,8 +266,8 @@ CHECKSUMS domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.5) + excon (1.5.0) + faraday (1.10.6) faraday-cookie_jar (0.0.8) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.1) @@ -275,7 +281,7 @@ CHECKSUMS faraday-retry (1.0.4) faraday_middleware (1.2.1) fastimage (2.4.1) - fastlane (2.234.0) + fastlane (2.237.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.100.0) @@ -292,8 +298,8 @@ CHECKSUMS http-cookie (1.0.8) httpclient (2.9.0) jmespath (1.6.2) - json (2.18.0) - jwt (2.10.2) + json (2.19.2) + jwt (2.10.3) logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 5ff2158a..32488095 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -1,23 +1,16 @@ -# CodePilot Security Scan Escalation +# CodePilot Security Maintenance Notice -Maintainer decision status: approved by maintainer on 2026-07-06. +Status reviewed: 2026-07-19. Public launch remains blocked on the maintainer-only items below; keep the documented fail-closed controls enabled. -Maintainer intervention was requested before public launch for these product-boundary decisions: +Detailed security findings are intentionally not stored in the public repository. Send vulnerability details, private identifiers, logs, and credential-related evidence through the maintainers' private security channel. -1. Authenticated arbitrary file download remains enabled. - - Surface: `gateway/codex_phone_gateway.py` exposes `/api/files/download?path=...`. - - Current behavior: any client with the gateway bearer token can request any absolute file path readable by the gateway process. - - Risk: a leaked or phished gateway token becomes direct local file disclosure, independent of Codex approval/sandbox controls. - - Decision: approved to proceed with the proposed hardening direction. Keep the feature, but narrow the allowed scope before public launch to uploaded files, explicit per-thread workspace roots, or short-lived file-preview grants. +Before public launch, maintainer coordination is still required for: -2. Localhost proxy sessions are bearerless capability URLs. - - Surface: `POST /api/local-web/sessions` requires the bearer token, but the returned `/api/local-web//...` URL is fetched without bearer auth. - - Current behavior: the random session id gates access for the session lifetime. - - Risk: anyone who obtains a live session URL can proxy GET requests to the selected loopback port through the public gateway until expiry. - - Decision: approved to proceed with the proposed hardening direction. Keep WebView compatibility, but shorten and constrain capability sessions before public launch unless an authenticated browser/proxy design is ready. +- 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. +- Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. -Local hardening already applied in this branch: - -- Removed public embedded private identifier patterns from the privacy audit and made local denylist patterns opt-in via `CODEPILOT_PRIVACY_PATTERNS_FILE`. -- Removed machine-specific default repository paths from local agent scripts. -- Changed gateway-created Codex app-server threads to use safe approval/sandbox settings unless dangerous mode is explicitly enabled. +Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests. diff --git a/scripts/agent-guard-bin/gh b/scripts/agent-guard-bin/gh index 857d61cf..083e9c13 100755 --- a/scripts/agent-guard-bin/gh +++ b/scripts/agent-guard-bin/gh @@ -1,5 +1,6 @@ #!/usr/bin/env zsh set -euo pipefail +umask 077 REAL_GH="${CODEPILOT_AGENT_REAL_GH:-}" if [[ -z "$REAL_GH" || ! -x "$REAL_GH" ]]; then @@ -9,15 +10,48 @@ fi ROOT="${CODEPILOT_REPO_ROOT:-$(pwd)}" AUTONOMY="${CODEPILOT_AGENT_PUBLIC_AUTONOMY:-draft}" +EXPECTED_REPO="${CODEPILOT_AGENT_GITHUB_REPO:-codepilotios/codepilot}" top="${1:-}" sub="${2:-}" run_privacy_audit() { if [[ -x "$ROOT/scripts/privacy-audit.sh" ]]; then - (cd "$ROOT" && "$ROOT/scripts/privacy-audit.sh" >/dev/null) + local outbound_file + outbound_file="$(mktemp "${TMPDIR:-/tmp}/codepilot-public-content.XXXXXX")" + trap 'rm -f "$outbound_file" 2>/dev/null || true' EXIT + printf '%s\n' "$@" > "$outbound_file" + local audit_status=0 + (cd "$ROOT" && CODEPILOT_PRIVACY_EXTERNAL_FILE="$outbound_file" "$ROOT/scripts/privacy-audit.sh" >/dev/null) || audit_status=$? + rm -f "$outbound_file" + trap - EXIT + return "$audit_status" fi } +body_files_are_tracked() { + local expect_body_file=0 + local body_file="" + local arg + for arg in "$@"; do + if (( expect_body_file == 1 )); then + body_file="$arg" + expect_body_file=0 + elif [[ "$arg" == "--body-file" ]]; then + expect_body_file=1 + continue + elif [[ "$arg" == --body-file=* ]]; then + body_file="${arg#--body-file=}" + else + continue + fi + + [[ -n "$body_file" && "$body_file" != "-" && -f "$ROOT/$body_file" ]] || return 1 + "$REAL_GIT" -C "$ROOT" ls-files --error-unmatch -- "$body_file" >/dev/null 2>&1 || return 1 + body_file="" + done + (( expect_body_file == 0 )) +} + has_arg() { local expected="$1" shift @@ -27,6 +61,72 @@ has_arg() { return 1 } +has_option_value() { + local long_option="$1" + local short_option="$2" + shift 2 + local expect_value=0 + local arg + for arg in "$@"; do + if (( expect_value == 1 )); then + [[ -n "$arg" ]] && return 0 + return 1 + fi + if [[ "$arg" == "$long_option" || "$arg" == "$short_option" ]]; then + expect_value=1 + continue + fi + if [[ "$arg" == "$long_option="* && -n "${arg#*=}" ]]; then + return 0 + fi + if [[ "$arg" == "$short_option"?* ]]; then + return 0 + fi + done + return 1 +} + +has_explicit_body() { + has_option_value "--body" "-b" "$@" || has_option_value "--body-file" "--body-file" "$@" +} + +uses_unaudited_content_source() { + local arg + for arg in "$@"; do + case "$arg" in + --editor|--web|--recover|--recover=*|--template|--template=*|--fill|--fill-first|--fill-verbose) + return 0 + ;; + esac + done + return 1 +} + +targets_expected_repo() { + local repo_target="" + local expect_repo_value=0 + local arg + for arg in "$@"; do + if (( expect_repo_value == 1 )); then + repo_target="$arg" + expect_repo_value=0 + continue + fi + case "$arg" in + --repo|-R) + expect_repo_value=1 + ;; + --repo=*) + repo_target="${arg#--repo=}" + ;; + -R*) + repo_target="${arg#-R}" + ;; + esac + done + (( expect_repo_value == 0 )) && [[ -z "$repo_target" || "$repo_target" == "$EXPECTED_REPO" ]] +} + case "$top:$sub" in auth:status|repo:view|repo:list|issue:list|issue:view|issue:status|pr:list|pr:view|pr:status|pr:checks|pr:diff|run:list|run:view|run:watch|workflow:list|workflow:view|release:list|release:view|label:list|search:*) exec "$REAL_GH" "$@" @@ -39,18 +139,29 @@ esac if [[ "$AUTONOMY" == "launch" ]]; then case "$top:$sub" in issue:create) - run_privacy_audit - exec "$REAL_GH" "$@" + if targets_expected_repo "$@" \ + && body_files_are_tracked "$@" \ + && has_option_value "--title" "-t" "$@" \ + && has_explicit_body "$@" \ + && ! uses_unaudited_content_source "$@"; then + run_privacy_audit "$@" || exit $? + exec "$REAL_GH" "$@" + fi ;; pr:create) - if has_arg "--draft" "$@"; then - run_privacy_audit + if has_arg "--draft" "$@" \ + && targets_expected_repo "$@" \ + && body_files_are_tracked "$@" \ + && has_option_value "--title" "-t" "$@" \ + && has_explicit_body "$@" \ + && ! uses_unaudited_content_source "$@"; then + run_privacy_audit "$@" || exit $? exec "$REAL_GH" "$@" fi ;; esac fi -echo "CodePilot agent guard blocked a public GitHub write: gh $*" >&2 +echo "CodePilot agent guard blocked a public GitHub write." >&2 echo "Launch autonomy only allows GitHub issues and draft PR creation after privacy audit." >&2 exit 77 diff --git a/scripts/agent-guard-bin/git b/scripts/agent-guard-bin/git index c2851cb3..722ff1d6 100755 --- a/scripts/agent-guard-bin/git +++ b/scripts/agent-guard-bin/git @@ -1,15 +1,108 @@ #!/usr/bin/env zsh set -euo pipefail +umask 077 REAL_GIT="${CODEPILOT_AGENT_REAL_GIT:-/usr/bin/git}" ROOT="${CODEPILOT_REPO_ROOT:-$(pwd)}" AUTONOMY="${CODEPILOT_AGENT_PUBLIC_AUTONOMY:-draft}" +EXPECTED_REPO="${CODEPILOT_AGENT_GITHUB_REPO:-codepilotios/codepilot}" +PUBLIC_AUTHOR_NAME="CodePilot" +PUBLIC_AUTHOR_EMAIL="codepilotios""@users.noreply.github.com" + +if [[ "${1:-}" == -* ]]; then + previous_arg="" + for arg in "$@"; do + if [[ "$previous_arg" == "-c" || "$previous_arg" == "--config-env" ]] && [[ "$arg" == alias.* ]]; then + echo "CodePilot agent guard blocked a command-line git alias." >&2 + exit 77 + fi + case "$arg" in + -calias.*|-c=alias.*|--config=alias.*|--config-env=alias.*) + echo "CodePilot agent guard blocked a command-line git alias." >&2 + exit 77 + ;; + commit|merge|rebase|cherry-pick|reset|checkout|switch|push|send-pack|http-push) + echo "CodePilot agent guard blocked git $arg with global options." >&2 + echo "Run guarded mutation commands from the target worktree without git global options." >&2 + exit 77 + ;; + esac + previous_arg="$arg" + done +fi current_branch() { "$REAL_GIT" branch --show-current 2>/dev/null || true } +is_configured_alias() { + local command_name="${1:-}" + [[ -n "$command_name" ]] || return 1 + "$REAL_GIT" config --get "alias.$command_name" >/dev/null 2>&1 +} + +origin_targets_expected_repo() { + local remote_url github_ssh_user="git" + remote_url="$("$REAL_GIT" remote get-url origin 2>/dev/null)" || return 1 + case "$remote_url" in + "https://github.com/$EXPECTED_REPO"|"https://github.com/$EXPECTED_REPO.git"|\ + "${github_ssh_user}@github.com:$EXPECTED_REPO"|"${github_ssh_user}@github.com:$EXPECTED_REPO.git"|\ + "ssh://${github_ssh_user}@github.com/$EXPECTED_REPO"|"ssh://${github_ssh_user}@github.com/$EXPECTED_REPO.git") + return 0 + ;; + esac + return 1 +} + +audit_push_commits() { + local source_ref="$1" + local destination_ref="$2" + local remote_tracking_ref="refs/remotes/origin/${destination_ref#refs/heads/}" + local base_ref="refs/remotes/origin/main" + local range metadata_file messages_file + + if "$REAL_GIT" show-ref --verify --quiet "$remote_tracking_ref"; then + base_ref="$remote_tracking_ref" + fi + "$REAL_GIT" rev-parse --verify --quiet "${source_ref}^{commit}" >/dev/null || return 1 + "$REAL_GIT" rev-parse --verify --quiet "${base_ref}^{commit}" >/dev/null || return 1 + range="${base_ref}..${source_ref}" + + metadata_file="$(mktemp "${TMPDIR:-/tmp}/codepilot-push-metadata.XXXXXX")" + messages_file="$(mktemp "${TMPDIR:-/tmp}/codepilot-push-messages.XXXXXX")" + "$REAL_GIT" log --format='%an%x09%ae%x09%cn%x09%ce' "$range" > "$metadata_file" + "$REAL_GIT" log --format='%B' "$range" > "$messages_file" + + local author_name author_email committer_name committer_email + while IFS=$'\t' read -r author_name author_email committer_name committer_email; do + if [[ "$author_name" != "$PUBLIC_AUTHOR_NAME" \ + || "$author_email" != "$PUBLIC_AUTHOR_EMAIL" \ + || "$committer_name" != "$PUBLIC_AUTHOR_NAME" \ + || "$committer_email" != "$PUBLIC_AUTHOR_EMAIL" ]]; then + rm -f "$metadata_file" "$messages_file" + echo "CodePilot agent guard blocked a push with non-public commit identity." >&2 + return 1 + fi + done < "$metadata_file" + + local audit_status=0 + if [[ -x "$ROOT/scripts/privacy-audit.sh" ]]; then + (cd "$ROOT" && CODEPILOT_PRIVACY_EXTERNAL_FILE="$messages_file" "$ROOT/scripts/privacy-audit.sh" >/dev/null) || audit_status=$? + fi + rm -f "$metadata_file" "$messages_file" + return "$audit_status" +} + +if [[ "${1:-}" != -* ]] && is_configured_alias "${1:-}"; then + echo "CodePilot agent guard blocked a configured git alias." >&2 + exit 77 +fi + case "${1:-}" in + send-pack|http-push) + echo "CodePilot agent guard blocked direct remote-write plumbing: git $1." >&2 + exit 77 + ;; commit|merge|rebase|cherry-pick|reset) branch="$(current_branch)" if [[ "$branch" == "main" || "$branch" == "master" ]]; then @@ -31,16 +124,57 @@ esac if [[ "${1:-}" == "push" ]]; then if [[ "$AUTONOMY" == "launch" ]]; then - for arg in "$@"; do + push_args=("${@:2}") + remote_seen=0 + allowed_remote=0 + refspec_count=0 + reject_push=0 + push_sources=() + push_destinations=() + + for arg in "${push_args[@]}"; do case "$arg" in - agent/*|HEAD:agent/*|refs/heads/agent/*) - if [[ -x "$ROOT/scripts/privacy-audit.sh" ]]; then - (cd "$ROOT" && "$ROOT/scripts/privacy-audit.sh" >/dev/null) + --all|--mirror|--tags|--follow-tags|--delete|-d|--prune) + reject_push=1 + ;; + -*) + ;; + *) + if (( remote_seen == 0 )); then + remote_seen=1 + [[ "$arg" == "origin" ]] && allowed_remote=1 + continue fi - exec "$REAL_GIT" "$@" + + refspec_count=$((refspec_count + 1)) + refspec="${arg#+}" + if [[ "$refspec" != *:* ]]; then + reject_push=1 + continue + fi + destination="${refspec##*:}" + case "$destination" in + refs/heads/agent/*) + push_sources+=("${refspec%%:*}") + push_destinations+=("$destination") + ;; + *) + reject_push=1 + ;; + esac ;; esac done + + if (( remote_seen == 1 && allowed_remote == 1 && refspec_count > 0 && reject_push == 0 )) && origin_targets_expected_repo; then + if [[ -x "$ROOT/scripts/privacy-audit.sh" ]]; then + (cd "$ROOT" && "$ROOT/scripts/privacy-audit.sh" >/dev/null) + fi + for (( index = 1; index <= ${#push_sources}; index++ )); do + audit_push_commits "${push_sources[$index]}" "${push_destinations[$index]}" || exit $? + done + exec "$REAL_GIT" "$@" + fi fi echo "CodePilot agent guard blocked git push." >&2 diff --git a/scripts/codepilot-agent-runner.sh b/scripts/codepilot-agent-runner.sh index d81aace1..22aceae5 100755 --- a/scripts/codepilot-agent-runner.sh +++ b/scripts/codepilot-agent-runner.sh @@ -1,5 +1,6 @@ #!/usr/bin/env zsh set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" DEFAULT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -46,7 +47,7 @@ fi printf '%s\n' "$$" > "$LOCK_DIR/pid" trap 'rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT -mkdir -p "$LOG_DIR" "$ROOT/ops/agents/escalations" "$WORKTREE_ROOT" +mkdir -p "$LOG_DIR" "$WORKTREE_ROOT" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" log_file="$LOG_DIR/$JOB-$timestamp.log" @@ -55,7 +56,6 @@ exec >>"$log_file" 2>&1 echo "== CodePilot agent: $JOB ==" echo "started_at=$timestamp" -echo "repo=$ROOT" cd "$ROOT" @@ -79,6 +79,9 @@ if [[ -d "$WORKTREE/.git" || -f "$WORKTREE/.git" ]]; then cd "$WORKTREE" fi +AGENT_ROOT="$(pwd -P)" +mkdir -p "$AGENT_ROOT/ops/agents/escalations" + git fetch --quiet origin main 2>/dev/null || true if git diff --quiet && git diff --cached --quiet; then if git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then @@ -97,10 +100,10 @@ export CODEPILOT_AGENT_REAL_GIT="$REAL_GIT" export CODEPILOT_AGENT_REAL_GH="$REAL_GH" export CODEPILOT_AGENT_REAL_ASC="$REAL_ASC" export CODEPILOT_AGENT_PUBLIC_AUTONOMY="$PUBLIC_AUTONOMY" -export CODEPILOT_REPO_ROOT="$ROOT" +export CODEPILOT_REPO_ROOT="$AGENT_ROOT" export PATH="$GUARD_BIN:$PATH" -ESCALATION_FILE="$ROOT/ops/agents/escalations/$JOB.md" +ESCALATION_FILE="$AGENT_ROOT/ops/agents/escalations/$JOB.md" BEFORE_HASH="" if [[ -f "$ESCALATION_FILE" ]]; then BEFORE_HASH="$(shasum -a 256 "$ESCALATION_FILE" | awk '{print $1}')" @@ -239,7 +242,7 @@ if [[ -n "$AFTER_HASH" && "$AFTER_HASH" != "$BEFORE_HASH" ]]; then echo "A CodePilot continuous agent needs intervention." echo echo "Agent: $JOB" - echo "Escalation file: $ESCALATION_FILE" + echo "Escalation file: ops/agents/escalations/$JOB.md" echo sed -n '1,220p' "$ESCALATION_FILE" } | "$CODEX_BIN" exec resume "$THREAD_ID" - diff --git a/scripts/export-desktop-sync.sh b/scripts/export-desktop-sync.sh index eeb623dd..184afb49 100755 --- a/scripts/export-desktop-sync.sh +++ b/scripts/export-desktop-sync.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +umask 077 CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" OUT_DIR="$HOME/.codex-account-switcher/desktop-sync-export" @@ -143,4 +144,5 @@ fi bundle="$OUT_DIR/codex-desktop-sync.tgz" tar -C "$payload" -czf "$bundle" . +chmod 600 "$bundle" echo "$bundle" diff --git a/scripts/import-desktop-sync.py b/scripts/import-desktop-sync.py index 6c503761..e9aaf94a 100755 --- a/scripts/import-desktop-sync.py +++ b/scripts/import-desktop-sync.py @@ -12,6 +12,10 @@ import time +MAX_ARCHIVE_MEMBERS = 10_000 +MAX_ARCHIVE_BYTES = 2 * 1024 * 1024 * 1024 + + def ordered(values): seen = set() result = [] @@ -26,14 +30,49 @@ def ordered(values): def safe_extract(bundle, destination): with tarfile.open(bundle, "r:gz") as archive: root = os.path.abspath(destination) - for member in archive.getmembers(): + members = archive.getmembers() + if len(members) > MAX_ARCHIVE_MEMBERS: + raise RuntimeError( + f"Refusing archive with more than {MAX_ARCHIVE_MEMBERS} members" + ) + + total_size = 0 + for member in members: target = os.path.abspath(os.path.join(destination, member.name)) if target != root and not target.startswith(root + os.sep): raise RuntimeError(f"Refusing unsafe archive member: {member.name}") - try: - archive.extractall(destination, filter="data") - except TypeError: - archive.extractall(destination) + if not (member.isdir() or member.isreg()): + raise RuntimeError(f"Refusing non-file archive member: {member.name}") + + if member.isreg(): + total_size += member.size + if total_size > MAX_ARCHIVE_BYTES: + raise RuntimeError( + f"Refusing archive larger than {MAX_ARCHIVE_BYTES} bytes" + ) + + if member.isdir(): + os.makedirs(target, mode=0o700, exist_ok=True) + os.chmod(target, 0o700) + continue + + parent = os.path.dirname(target) + os.makedirs(parent, mode=0o700, exist_ok=True) + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"Could not read archive member: {member.name}") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + with source: + descriptor = os.open(target, flags, 0o600) + try: + with os.fdopen(descriptor, "wb") as output: + descriptor = -1 + shutil.copyfileobj(source, output) + finally: + if descriptor >= 0: + os.close(descriptor) def load_json(path): diff --git a/scripts/install-codepilot-local-agents.sh b/scripts/install-codepilot-local-agents.sh index e2e03881..79393072 100755 --- a/scripts/install-codepilot-local-agents.sh +++ b/scripts/install-codepilot-local-agents.sh @@ -1,5 +1,6 @@ #!/usr/bin/env zsh set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="${CODEPILOT_REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" @@ -29,7 +30,9 @@ fi chmod +x "$RUNNER" "$SCHEDULER" "$ROOT/scripts/codepilot-agent-escalate.sh" mkdir -p "$PLIST_DIR" mkdir -p "$STATE_DIR" +chmod 700 "$STATE_DIR" printf '%s\n' "$THREAD_ID" > "$STATE_DIR/thread-id" +chmod 600 "$STATE_DIR/thread-id" for old_plist in "$PLIST_DIR"/io.codepilot.agent.*.plist; do [ -f "$old_plist" ] || continue @@ -75,6 +78,8 @@ write_scheduler_plist() { CODEPILOT_AGENT_THREAD_ID $THREAD_ID + Umask + 63 StandardOutPath $HOME/Library/Logs/CodePilotAgents/scheduler.launchd.out.log StandardErrorPath @@ -82,6 +87,7 @@ write_scheduler_plist() { EOF + chmod 600 "$plist" launchctl unload "$plist" 2>/dev/null || true launchctl load "$plist" } diff --git a/scripts/install-phone-gateway-agent.sh b/scripts/install-phone-gateway-agent.sh index e08cf775..ae9ddf5a 100755 --- a/scripts/install-phone-gateway-agent.sh +++ b/scripts/install-phone-gateway-agent.sh @@ -45,6 +45,7 @@ fi /usr/bin/python3 - "$ROOT" "$PLIST" "$LABEL" "$gateway_python" <<'PY' import plistlib +import os import sys from pathlib import Path @@ -60,10 +61,11 @@ allowed_env_keys = { "CODEX_PHONE_APNS_KEY_ID", "CODEX_PHONE_APNS_KEY_PATH", "CODEX_PHONE_APNS_TOPIC", - "SUPABASE_ACCESS_TOKEN", + "CODEPILOT_FILE_DOWNLOAD_ROOTS", } environment = {} if env_path.is_file(): + os.chmod(env_path, 0o600) for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: @@ -86,6 +88,7 @@ plist = { ], "RunAtLoad": True, "KeepAlive": True, + "Umask": 0o077, "WorkingDirectory": str(root), "StandardOutPath": str(Path.home() / "Library" / "Logs" / "codex-phone-gateway.out.log"), "StandardErrorPath": str(Path.home() / "Library" / "Logs" / "codex-phone-gateway.err.log"), @@ -93,6 +96,7 @@ plist = { if environment: plist["EnvironmentVariables"] = environment plist_path.write_bytes(plistlib.dumps(plist, sort_keys=False)) +os.chmod(plist_path, 0o600) PY gateway_jobs_state() { @@ -108,11 +112,15 @@ gateway_jobs_state() { echo "unknown" return 0 } + if [[ "$token" == *[^A-Za-z0-9_-]* ]]; then + echo "unknown" + return 0 + fi local response - response="$(/usr/bin/curl -fsS --max-time 2 \ - -H "Authorization: Bearer $token" \ - "http://127.0.0.1:18790/api/jobs/active" 2>/dev/null || true)" + response="$(printf 'header = "Authorization: Bearer %s"\n' "$token" | \ + /usr/bin/curl -fsS --max-time 2 --config - \ + "http://127.0.0.1:18790/api/jobs/active" 2>/dev/null || true)" [[ -n "$response" ]] || { echo "unknown" return 0 diff --git a/scripts/install-switcher-agent.sh b/scripts/install-switcher-agent.sh index 4245ead4..5d6aebd0 100755 --- a/scripts/install-switcher-agent.sh +++ b/scripts/install-switcher-agent.sh @@ -15,6 +15,7 @@ mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" /usr/bin/python3 - "$ROOT" "$EXECUTABLE" "$PLIST" "$LABEL" <<'PY' import plistlib +import os import sys from pathlib import Path @@ -31,6 +32,7 @@ plist = { "KeepAlive": { "SuccessfulExit": False, }, + "Umask": 0o077, "WorkingDirectory": str(root), "EnvironmentVariables": { "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", @@ -40,6 +42,7 @@ plist = { "LimitLoadToSessionType": "Aqua", } plist_path.write_bytes(plistlib.dumps(plist, sort_keys=False)) +os.chmod(plist_path, 0o600) PY launchctl bootout "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true diff --git a/scripts/privacy-audit.sh b/scripts/privacy-audit.sh index 0a602d81..25958a71 100755 --- a/scripts/privacy-audit.sh +++ b/scripts/privacy-audit.sh @@ -24,7 +24,7 @@ if [[ -n "${CODEPILOT_PRIVATE_AUDIT_PATTERNS_FILE:-}${CODEPILOT_PRIVACY_PATTERNS fi if [[ -f "$private_patterns_file" ]]; then - if LC_ALL=C grep -nI -F -f "$private_patterns_file" -- "${candidate_files[@]}"; then + if LC_ALL=C grep -qI -E -f "$private_patterns_file" -- "${candidate_files[@]}"; then echo "privacy audit failed: repository files contain private identifiers" >&2 exit 1 fi @@ -33,41 +33,69 @@ fi users_dir_pattern="/$(printf %s Users)/[^[:space:]\"']+" generic_private_patterns=( "$users_dir_pattern" + '/home/[^[:space:]"'\'']+' + 'C:[\]Users[\][^[:space:]"'\'']+' '[A-Za-z][A-Za-z0-9._%+-]*@[A-Za-z][A-Za-z0-9.-]*\.[A-Za-z]{2,}' '(com|io)\.[A-Za-z0-9_-]+\.codexphone' + "[A-Z][A-Za-z]+'s iPhone" + 'https://ota\.internal\.invalid/' ) audit_files=() for file in "${candidate_files[@]}"; do [[ "$file" == "scripts/privacy-audit.sh" ]] && continue + [[ "$file" == "scripts/tests/privacy-audit-test.sh" ]] && continue audit_files+=("$file") done +if [[ -n "${CODEPILOT_PRIVACY_EXTERNAL_FILE:-}" ]]; then + if [[ ! -f "$CODEPILOT_PRIVACY_EXTERNAL_FILE" ]]; then + echo "privacy audit failed: configured external file is unavailable" >&2 + exit 2 + fi + audit_files+=("$CODEPILOT_PRIVACY_EXTERNAL_FILE") +fi generic_pattern="$(IFS='|'; echo "${generic_private_patterns[*]}")" -if LC_ALL=C grep -nI -E "$generic_pattern" -- "${audit_files[@]}"; then +if (( ${#audit_files[@]} > 0 )) && LC_ALL=C grep -qI -E "$generic_pattern" -- "${audit_files[@]}"; then echo "privacy audit failed: repository files contain private paths or email addresses" >&2 exit 1 fi secret_patterns=( "g""hp_[A-Za-z0-9_]{20,}" + "g""ho_[A-Za-z0-9_]{20,}" + "g""hu_[A-Za-z0-9_]{20,}" "github_""pat_[A-Za-z0-9_]{20,}" "(^|[^A-Za-z0-9])s""k-[A-Za-z0-9]{20,}" - "-----BEGIN (RSA|OPENSSH|PRIVATE) ""KEY" + "(^|[^A-Za-z0-9])s""k-proj-[A-Za-z0-9]{20,}" + "sbp_[A-Za-z0-9_]{20,}" + "sb_secret_[A-Za-z0-9_]{20,}" + "-----BEGIN RSA PRIVATE ""KEY" + "-----BEGIN OPENSSH PRIVATE ""KEY" + "-----BEGIN PRIVATE ""KEY" + "-----BEGIN EC PRIVATE ""KEY-----" 'Bearer [A-Za-z0-9._-]{20,}' + 'AKIA[A-Z0-9]{16}' + "AI""za[0-9A-Za-z_-]{35}" + "xox""b-[A-Za-z0-9-]{20,}" + 'xapp-[A-Za-z0-9-]{20,}' + "s""k_live_[A-Za-z0-9]{20,}" + 'glpat-[A-Za-z0-9_-]{20,}' + 'npm_[A-Za-z0-9]{20,}' + 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{20,}' "client_""secret[[:space:]]*[:=]" "private_""key[[:space:]]*[:=]" ) secret_pattern="$(IFS='|'; echo "${secret_patterns[*]}")" -if LC_ALL=C grep -nI -E "$secret_pattern" -- "${audit_files[@]}"; then +if (( ${#audit_files[@]} > 0 )) && LC_ALL=C grep -qI -E "$secret_pattern" -- "${audit_files[@]}"; then echo "privacy audit failed: repository files contain secret-looking material" >&2 exit 1 fi noncanonical_public_url_pattern='https://(codepilotios\.github\.io|github\.com/codepilotios)/CodePilot' -if git grep -n -I -E "$noncanonical_public_url_pattern" -- "${audit_files[@]}"; then +if (( ${#audit_files[@]} > 0 )) && LC_ALL=C grep -qI -E "$noncanonical_public_url_pattern" -- "${audit_files[@]}"; then echo "privacy audit failed: tracked files contain noncanonical public CodePilot URLs" >&2 exit 1 fi diff --git a/scripts/push-desktop-sync.sh b/scripts/push-desktop-sync.sh index f3ee7bd2..dc47391b 100755 --- a/scripts/push-desktop-sync.sh +++ b/scripts/push-desktop-sync.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +umask 077 if [[ $# -lt 1 ]]; then echo "usage: $0 user@macbook-air [--host-id HOST_ID]" >&2 @@ -9,15 +10,20 @@ fi destination="$1" shift +if [[ ! "$destination" =~ ^([A-Za-z0-9._-]+@)?([A-Za-z0-9][A-Za-z0-9._-]*|\[[0-9A-Fa-f:.%]+\])$ ]]; then + echo "destination must be a host or user@host without SSH options, paths, or whitespace" >&2 + exit 1 +fi + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" bundle="$("$script_dir/export-desktop-sync.sh")" remote_dir=".codex-account-switcher/desktop-sync-import" -ssh "$destination" "mkdir -p ~/$remote_dir" -scp "$bundle" "$destination:~/$remote_dir/codex-desktop-sync.tgz" -scp "$script_dir/import-desktop-sync.py" "$destination:~/$remote_dir/import-desktop-sync.py" -scp "$script_dir/repair-remote-project-hosts.py" "$destination:~/$remote_dir/repair-remote-project-hosts.py" +ssh -- "$destination" "mkdir -p ~/$remote_dir" +scp -- "$bundle" "$destination:~/$remote_dir/codex-desktop-sync.tgz" +scp -- "$script_dir/import-desktop-sync.py" "$destination:~/$remote_dir/import-desktop-sync.py" +scp -- "$script_dir/repair-remote-project-hosts.py" "$destination:~/$remote_dir/repair-remote-project-hosts.py" remote_args=(python3 "$remote_dir/import-desktop-sync.py" "$remote_dir/codex-desktop-sync.tgz" --relaunch "$@") printf -v remote_cmd ' %q' "${remote_args[@]}" -ssh "$destination" "cd ~ &&${remote_cmd}" +ssh -- "$destination" "cd ~ &&${remote_cmd}" diff --git a/scripts/save-fastlane-session.sh b/scripts/save-fastlane-session.sh index b2524ae7..893a1196 100755 --- a/scripts/save-fastlane-session.sh +++ b/scripts/save-fastlane-session.sh @@ -23,6 +23,7 @@ fi python3 - "$ENV_FILE" "$tmp" <<'PY' from pathlib import Path +import os import sys env_path = Path(sys.argv[1]) @@ -47,6 +48,7 @@ if not updated: out.append(line) env_path.write_text("\n".join(out) + "\n", encoding="utf-8") +os.chmod(env_path, 0o600) PY echo "Saved FASTLANE_SESSION to $ENV_FILE" diff --git a/scripts/setup-cloudflare-remote-access.sh b/scripts/setup-cloudflare-remote-access.sh index 0d7eb4c0..930e4a1c 100755 --- a/scripts/setup-cloudflare-remote-access.sh +++ b/scripts/setup-cloudflare-remote-access.sh @@ -1,5 +1,6 @@ #!/bin/zsh set -euo pipefail +umask 077 ROOT="$(cd "$(dirname "$0")/.." && pwd)" APP_DIR="$HOME/.codex-account-switcher" @@ -10,8 +11,66 @@ METADATA_PATH="$APP_DIR/cloudflare-setup.json" LABEL="${CODEPILOT_CLOUDFLARED_LAUNCHD_LABEL:-io.codepilot.phone-cloudflared}" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" GATEWAY_URL="${CODEPILOT_GATEWAY_URL:-http://127.0.0.1:18790}" +GATEWAY_TOKEN_PATH="${CODEPILOT_GATEWAY_TOKEN_PATH:-$APP_DIR/phone-gateway-token}" mkdir -p "$APP_DIR" "$CLOUDFLARED_DIR" +chmod 700 "$APP_DIR" "$CLOUDFLARED_DIR" + +validate_gateway_url() { + /usr/bin/python3 - "$GATEWAY_URL" <<'PY' +import ipaddress +import sys +from urllib.parse import urlsplit + +value = sys.argv[1] +try: + parsed = urlsplit(value) + host = parsed.hostname or "" + port = parsed.port +except ValueError as error: + raise SystemExit(f"Invalid CodePilot gateway URL: {error}") + +is_loopback = host.casefold() == "localhost" +if not is_loopback: + try: + is_loopback = ipaddress.ip_address(host).is_loopback + except ValueError: + is_loopback = False + +if ( + parsed.scheme != "http" + or not is_loopback + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + or port is None +): + raise SystemExit("CodePilot gateway URL must be a loopback HTTP origin with an explicit port") +PY +} + +validate_tunnel_inputs() { + local hostname="$1" + local tunnel_name="$2" + /usr/bin/python3 - "$hostname" "$tunnel_name" <<'PY' +import re +import sys + +hostname = sys.argv[1] +tunnel_name = sys.argv[2] +labels = hostname.split(".") +if ( + len(hostname) > 253 + or len(labels) < 2 + or any(not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in labels) +): + raise SystemExit("Cloudflare hostname must be a valid fully qualified DNS name") +if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,62}", tunnel_name): + raise SystemExit("Cloudflare tunnel name may contain only letters, numbers, underscores, and hyphens") +PY +} cloudflared_bin() { if command -v cloudflared >/dev/null 2>&1; then @@ -69,6 +128,7 @@ payload = { } path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2) + "\n") +path.chmod(0o600) PY } @@ -160,6 +220,8 @@ configure_permanent() { echo "--hostname is required" >&2 exit 2 } + validate_tunnel_inputs "$hostname" "$tunnel_name" + validate_gateway_url local cf create_output tunnel_id cf="$(cloudflared_bin)" || { @@ -179,6 +241,7 @@ ingress: service: $GATEWAY_URL - service: http_status:404 EOF + chmod 600 "$CONFIG_PATH" write_metadata permanent "$hostname" "$tunnel_name" "$tunnel_id" echo "Configured Cloudflare Tunnel for https://$hostname" @@ -188,6 +251,7 @@ install_service() { mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" /usr/bin/python3 - "$ROOT" "$PLIST" "$LABEL" <<'PY' import plistlib +import os import sys from pathlib import Path @@ -201,11 +265,13 @@ plist = { ], "RunAtLoad": True, "KeepAlive": True, + "Umask": 0o077, "WorkingDirectory": str(root), "StandardOutPath": str(Path.home() / "Library" / "Logs" / "codepilot-cloudflared.out.log"), "StandardErrorPath": str(Path.home() / "Library" / "Logs" / "codepilot-cloudflared.err.log"), } plist_path.write_bytes(plistlib.dumps(plist, sort_keys=False)) +os.chmod(plist_path, 0o600) PY launchctl bootout "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true @@ -233,11 +299,61 @@ verify_url() { echo "--url is required" >&2 exit 2 } - curl -fsS "$url/api/health" >/dev/null + url="$(/usr/bin/python3 - "$url" "$METADATA_PATH" <<'PY' +import json +import stat +import sys +from pathlib import Path +from urllib.parse import urlsplit + +value = sys.argv[1] +metadata_path = Path(sys.argv[2]) +try: + metadata_stat = metadata_path.lstat() + if stat.S_ISLNK(metadata_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode): + raise ValueError("setup metadata must be a regular file") + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + expected_host = str(metadata.get("hostname") or "").casefold() + parsed = urlsplit(value) + port = parsed.port +except (OSError, ValueError, json.JSONDecodeError) as error: + raise SystemExit(f"Cannot verify an untrusted Cloudflare URL: {error}") + +if ( + metadata.get("mode") != "permanent" + or not expected_host + or parsed.scheme != "https" + or (parsed.hostname or "").casefold() != expected_host + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + or port not in {None, 443} +): + raise SystemExit("Verification URL must be the configured Cloudflare HTTPS origin") + +print(f"https://{expected_host}") +PY +)" + [ -f "$GATEWAY_TOKEN_PATH" ] || { + echo "CodePilot gateway token is missing. Start the gateway before verifying remote access." >&2 + exit 22 + } + local token + token="$(tr -d '\r\n' < "$GATEWAY_TOKEN_PATH")" + if [[ -z "$token" || "$token" == *[^A-Za-z0-9_-]* ]]; then + echo "CodePilot gateway token is invalid. Rotate the token before verifying remote access." >&2 + exit 22 + fi + printf 'header = "Authorization: Bearer %s"\n' "$token" | \ + curl -fsS --proto '=https' --proto-redir '=https' --max-redirs 0 \ + --connect-timeout 10 --max-time 20 --config - "$url/api/health" >/dev/null echo "verified $url" } start_trycloudflare() { + validate_gateway_url local cf cf="$(cloudflared_bin)" || { echo "cloudflared is missing." >&2 diff --git a/scripts/test-cloudflare-setup.sh b/scripts/test-cloudflare-setup.sh index 9c94900c..aaf2115f 100755 --- a/scripts/test-cloudflare-setup.sh +++ b/scripts/test-cloudflare-setup.sh @@ -38,7 +38,18 @@ esac ' write_stub brew 'echo "brew $*"' write_stub launchctl 'echo "launchctl $*"' -write_stub curl 'echo "{\"ok\":true}"' +write_stub curl ' +[[ "$*" == *"--config -"* ]] || exit 90 +[[ "$*" == *"--proto =https"* ]] || exit 92 +[[ "$*" == *"--proto-redir =https"* ]] || exit 93 +[[ "$*" == *"--max-redirs 0"* ]] || exit 94 +[[ "$*" == *"https://codepilot.example.com/api/health"* ]] || exit 95 +config="$(cat)" +[[ "$config" == '\''header = "Authorization: Bearer test-gateway-token"'\'' ]] || exit 91 +echo "{\"ok\":true}" +' +mkdir -p "$HOME/.codex-account-switcher" +printf '%s\n' "test-gateway-token" > "$HOME/.codex-account-switcher/phone-gateway-token" "$SCRIPT" status >/tmp/codepilot-status.json 2>/tmp/codepilot-status.err || true grep -q "No such file" /tmp/codepilot-status.err && fail "status should not crash when config is missing" @@ -47,14 +58,36 @@ grep -q "No such file" /tmp/codepilot-status.err && fail "status should not cras [ -f "$HOME/.cloudflared/codepilot-config.yaml" ] || fail "config file missing" grep -q "hostname: codepilot.example.com" "$HOME/.cloudflared/codepilot-config.yaml" || fail "hostname missing from config" grep -q "service: http://127.0.0.1:18790" "$HOME/.cloudflared/codepilot-config.yaml" || fail "gateway service missing from config" +[ "$(stat -f '%Lp' "$HOME/.cloudflared/codepilot-config.yaml")" = "600" ] || fail "config must be owner-only" [ -f "$HOME/.codex-account-switcher/cloudflare-setup.json" ] || fail "metadata missing" ! grep -qi "token" "$HOME/.codex-account-switcher/cloudflare-setup.json" || fail "metadata must not contain token" +[ "$(stat -f '%Lp' "$HOME/.codex-account-switcher/cloudflare-setup.json")" = "600" ] || fail "metadata must be owner-only" + +if "$SCRIPT" configure-permanent --hostname $'safe.example.com\nservice: http://169.254.169.254' --tunnel-name codepilot >/dev/null 2>&1; then + fail "hostname YAML injection was accepted" +fi +if "$SCRIPT" configure-permanent --hostname codepilot.example.com --tunnel-name $'codepilot\ningress' >/dev/null 2>&1; then + fail "tunnel-name YAML injection was accepted" +fi +if CODEPILOT_GATEWAY_URL="http://192.0.2.10:18790" "$SCRIPT" start-trycloudflare >/dev/null 2>&1; then + fail "non-loopback tunnel target was accepted" +fi "$SCRIPT" install-service [ -f "$HOME/Library/LaunchAgents/io.codepilot.phone-cloudflared.plist" ] || fail "LaunchAgent plist missing" "$SCRIPT" verify --url https://codepilot.example.com >/tmp/codepilot-verify.out grep -q "verified" /tmp/codepilot-verify.out || fail "verify output should say verified" +for unsafe_url in \ + http://codepilot.example.com \ + https://other.example.com \ + https://codepilot.example.com.evil.test \ + https://user:@codepilot.example.com \ + 'https://codepilot.example.com/?next=https://evil.test'; do + if "$SCRIPT" verify --url "$unsafe_url" >/dev/null 2>&1; then + fail "unsafe verification URL was accepted" + fi +done "$SCRIPT" start-trycloudflare >/tmp/codepilot-try.out grep -q "temporary.trycloudflare.com" /tmp/codepilot-try.out || fail "temporary URL missing" diff --git a/scripts/tests/background-agent-opt-in-test.sh b/scripts/tests/background-agent-opt-in-test.sh new file mode 100755 index 00000000..f5782b38 --- /dev/null +++ b/scripts/tests/background-agent-opt-in-test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SOURCE="$ROOT/Sources/CodexAccountSwitcher/main.swift" +INSTALLER="$ROOT/scripts/install-codepilot-local-agents.sh" + +if grep -Eq 'threadID[^=]*=.*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' "$SOURCE"; then + echo "background-agent guard failed: app source contains a fallback thread identifier" >&2 + exit 1 +fi + +grep -Fq 'appDir.appendingPathComponent("agents/thread-id")' "$SOURCE" +grep -Fq '!threadID.isEmpty else' "$SOURCE" +grep -Fq 'umask 077' "$INSTALLER" +grep -Fq 'chmod 600 "$STATE_DIR/thread-id"' "$INSTALLER" +grep -Fq 'chmod 600 "$plist"' "$INSTALLER" + +echo "background-agent opt-in guard passed" diff --git a/scripts/tests/codepilot-agent-public-write-guard-test.sh b/scripts/tests/codepilot-agent-public-write-guard-test.sh index 180acdba..ded6c46d 100755 --- a/scripts/tests/codepilot-agent-public-write-guard-test.sh +++ b/scripts/tests/codepilot-agent-public-write-guard-test.sh @@ -22,6 +22,31 @@ if [[ "$1" == "branch" && "$2" == "--show-current" ]]; then printf '%s\n' "${CODEPILOT_FAKE_GIT_BRANCH:-agent/test}" exit 0 fi +if [[ "$1" == "remote" && "$2" == "get-url" && "$3" == "origin" ]]; then + printf '%s\n' "${CODEPILOT_FAKE_GIT_REMOTE_URL:-https://github.com/codepilotios/codepilot.git}" + exit 0 +fi +if [[ "$1" == "show-ref" ]]; then + exit 1 +fi +if [[ "$1" == "rev-parse" ]]; then + exit 0 +fi +if [[ "$1" == "log" && "$2" == --format=* ]]; then + case "$2" in + *%an*) printf '%s\t%s\t%s\t%s\n' "${CODEPILOT_FAKE_GIT_AUTHOR_NAME:-CodePilot}" "codepilotios""@users.noreply.github.com" "CodePilot" "codepilotios""@users.noreply.github.com" ;; + *%B*) printf '%s\n' "${CODEPILOT_FAKE_GIT_MESSAGE:-Public hardening change}" ;; + esac + exit 0 +fi +if [[ "$1" == "config" && "$2" == "--get" && "$3" == "alias.publish" ]]; then + [[ "${CODEPILOT_FAKE_GIT_ALIAS:-}" == "publish" ]] || exit 1 + printf '%s\n' 'push' + exit 0 +fi +if [[ "$1" == "config" && "$2" == "--get" ]]; then + exit 1 +fi printf '%s\n' "$@" > "$CODEPILOT_GUARD_CAPTURE" EOF @@ -29,7 +54,8 @@ chmod +x "$TMP_ROOT/gh" "$TMP_ROOT/git" export CODEPILOT_GUARD_CAPTURE="$TMP_ROOT/capture" export CODEPILOT_AGENT_REAL_GH="$TMP_ROOT/gh" export CODEPILOT_AGENT_REAL_GIT="$TMP_ROOT/git" -export CODEPILOT_AGENT_PUBLIC_AUTONOMY="review" +export CODEPILOT_AGENT_PUBLIC_AUTONOMY="draft" +export CODEPILOT_REPO_ROOT="$ROOT" "$GUARD_BIN/gh" issue list --repo codepilotios/codepilot grep -qx 'list' "$TMP_ROOT/capture" @@ -122,15 +148,121 @@ if "$GUARD_BIN/git" push origin main; then exit 1 fi +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" -C "$ROOT" push origin main; then + echo "Launch autonomy allowed git -C to bypass the push guard" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" -c alias.publish=push publish origin main; then + echo "Launch autonomy allowed a command-line alias to bypass the push guard" >&2 + exit 1 +fi + +if CODEPILOT_FAKE_GIT_ALIAS="publish" CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" publish origin main; then + echo "Launch autonomy allowed a configured alias to bypass the push guard" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" send-pack origin main; then + echo "Launch autonomy allowed direct send-pack remote writes" >&2 + exit 1 +fi + CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" issue create --title "Setup issue" --body "Drafted by agent" grep -qx 'create' "$TMP_ROOT/capture" +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" issue create --title "person""@example.net" --body "Drafted by agent"; then + echo "Launch autonomy allowed private outbound issue content" >&2 + exit 1 +fi + +print -r -- "Drafted by agent" > "$TMP_ROOT/issue-body.md" +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" issue create --title "Setup issue" --body-file "$TMP_ROOT/issue-body.md"; then + echo "Launch autonomy allowed an untracked issue body file" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" issue create --title "Setup issue" --body-file -; then + echo "Launch autonomy allowed an unaudited stdin issue body" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" issue create --title "Setup issue" --recover saved-input; then + echo "Launch autonomy allowed recovered issue content" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" issue create --repo example/other --title unsafe; then + echo "Launch autonomy allowed issue creation outside the CodePilot repository" >&2 + exit 1 +fi + CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" pr create --draft --title "Docs update" --body "Prepared by agent" grep -qx 'create' "$TMP_ROOT/capture" -CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:agent/presence-maintenance +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" pr create --draft --fill; then + echo "Launch autonomy allowed unaudited commit text to fill a pull request" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/gh" pr create --draft --title "Docs update" --recover saved-input; then + echo "Launch autonomy allowed recovered pull request content" >&2 + exit 1 +fi + +CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:refs/heads/agent/presence-maintenance grep -qx 'push' "$TMP_ROOT/capture" +if CODEPILOT_FAKE_GIT_AUTHOR_NAME="Private Author" CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:refs/heads/agent/presence-maintenance; then + echo "Launch autonomy allowed a push with non-public commit identity" >&2 + exit 1 +fi + +if CODEPILOT_FAKE_GIT_MESSAGE="Contact person""@example.net" CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:refs/heads/agent/presence-maintenance; then + echo "Launch autonomy allowed a push with private commit text" >&2 + exit 1 +fi + +if CODEPILOT_FAKE_GIT_REMOTE_URL="https://github.com/example/other.git" CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:refs/heads/agent/presence-maintenance; then + echo "Launch autonomy allowed a push through an unexpected origin URL" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:refs/heads/agent/presence-maintenance HEAD:refs/heads/main; then + echo "Launch autonomy allowed a protected refspec beside an agent refspec" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push --all origin HEAD:refs/heads/agent/presence-maintenance; then + echo "Launch autonomy allowed a broad push mode" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push --follow-tags origin HEAD:refs/heads/agent/presence-maintenance; then + echo "Launch autonomy allowed an implicit tag push" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push upstream HEAD:refs/heads/agent/presence-maintenance; then + echo "Launch autonomy allowed a push outside the CodePilot origin" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin HEAD:agent/presence-maintenance; then + echo "Launch autonomy allowed an ambiguous short destination ref" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin agent/presence-maintenance; then + echo "Launch autonomy allowed a tag-like short refspec" >&2 + exit 1 +fi + +if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin; then + echo "Launch autonomy allowed a push without an explicit agent refspec" >&2 + exit 1 +fi + if CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" "$GUARD_BIN/git" push origin main; then echo "Launch autonomy allowed pushing main" >&2 exit 1 diff --git a/scripts/tests/codepilot-agent-runner-model-test.sh b/scripts/tests/codepilot-agent-runner-model-test.sh index 52b9f31a..67410f0a 100755 --- a/scripts/tests/codepilot-agent-runner-model-test.sh +++ b/scripts/tests/codepilot-agent-runner-model-test.sh @@ -4,17 +4,19 @@ set -euo pipefail unset CODEPILOT_AGENT_MODEL CODEPILOT_AGENT_REASONING_EFFORT export CODEPILOT_AGENT_PUBLIC_AUTONOMY="launch" -TEST_ROOT="$(mktemp -d)" -ROOT="$TEST_ROOT/repo" -STATE_DIR="$TEST_ROOT/state" +ROOT="$(mktemp -d)" +STATE_DIR="$(mktemp -d)" LOG_DIR="$ROOT/logs" JOB="health-watch" CAPTURE="$ROOT/codex-args" PROMPT_CAPTURE="$ROOT/codex-prompt" -ASC_CAPTURE="$ROOT/asc-path" +WORKTREE_CAPTURE="$ROOT/codex-worktree" + +unset CODEPILOT_AGENT_MODEL CODEPILOT_AGENT_REASONING_EFFORT cleanup() { - rm -rf "$TEST_ROOT" + rm -rf "$ROOT" + rm -rf "$STATE_DIR" } trap cleanup EXIT @@ -29,7 +31,7 @@ printf 'Check health.\n' > "$ROOT/ops/agents/prompts/$JOB.md" cat > "$ROOT/codex-stub" <<'EOF' #!/bin/zsh printf '%s\n' "$@" > "$CODEPILOT_TEST_CAPTURE" -printf '%s\n' "${CODEPILOT_AGENT_REAL_ASC:-}" > "$CODEPILOT_TEST_ASC_CAPTURE" +printf '%s\n%s\n' "$PWD" "$CODEPILOT_REPO_ROOT" > "$CODEPILOT_TEST_WORKTREE_CAPTURE" cat > "$CODEPILOT_TEST_PROMPT_CAPTURE" EOF chmod +x "$ROOT/codex-stub" @@ -47,7 +49,7 @@ run_runner() { CODEPILOT_CODEX_BIN="$ROOT/codex-stub" \ CODEPILOT_TEST_CAPTURE="$CAPTURE" \ CODEPILOT_TEST_PROMPT_CAPTURE="$PROMPT_CAPTURE" \ - CODEPILOT_TEST_ASC_CAPTURE="$ASC_CAPTURE" \ + CODEPILOT_TEST_WORKTREE_CAPTURE="$WORKTREE_CAPTURE" \ TMPDIR="$ROOT/tmp" \ PATH="$ROOT/bin:$PATH" \ "$PWD/scripts/codepilot-agent-runner.sh" "$JOB" @@ -57,8 +59,12 @@ mkdir -p "$ROOT/tmp" run_runner -if [[ "$(<"$ASC_CAPTURE")" != "$ROOT/bin/asc" ]]; then - echo "Runner did not export the real asc executable before installing guards" >&2 +expected_worktree="$STATE_DIR/worktrees/$JOB" +expected_audit_root="$(cd "$expected_worktree" && pwd -P)" +captured_worktree="$(sed -n '1p' "$WORKTREE_CAPTURE")" +captured_worktree="$(cd "$captured_worktree" && pwd -P)" +if [[ "$captured_worktree" != "$expected_audit_root" || "$(sed -n '2p' "$WORKTREE_CAPTURE")" != "$expected_audit_root" ]]; then + echo "Runner did not scope the agent and public-write audit to the isolated worktree" >&2 exit 1 fi diff --git a/scripts/tests/gateway-token-argv-test.sh b/scripts/tests/gateway-token-argv-test.sh new file mode 100755 index 00000000..8a3d19cf --- /dev/null +++ b/scripts/tests/gateway-token-argv-test.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +INSTALLER="$ROOT/scripts/install-phone-gateway-agent.sh" +TUNNEL_SETUP="$ROOT/scripts/setup-cloudflare-remote-access.sh" + +if grep -Eq -- '(-H|--header)[[:space:]]+"Authorization: Bearer \\$token"' "$INSTALLER" "$TUNNEL_SETUP"; then + echo "Gateway tooling exposes the bearer token through curl arguments" >&2 + exit 1 +fi + +grep -Fq -- '--config -' "$INSTALLER" +grep -Fq -- 'printf '\''header = "Authorization: Bearer %s"' "$INSTALLER" +grep -Fq -- '--config -' "$TUNNEL_SETUP" +grep -Fq -- 'printf '\''header = "Authorization: Bearer %s"' "$TUNNEL_SETUP" + +echo "Gateway token argv test passed." diff --git a/scripts/tests/launch-agent-umask-test.sh b/scripts/tests/launch-agent-umask-test.sh new file mode 100755 index 00000000..dac0cb55 --- /dev/null +++ b/scripts/tests/launch-agent-umask-test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +grep -Fq '"Umask": 0o077' "$ROOT/Sources/CodexAccountSwitcher/main.swift" +grep -Fq '"Umask": 0o077' "$ROOT/scripts/install-phone-gateway-agent.sh" +grep -Fq '"Umask": 0o077' "$ROOT/scripts/install-switcher-agent.sh" +grep -Fq '"Umask": 0o077' "$ROOT/scripts/setup-cloudflare-remote-access.sh" +grep -Fq 'umask 077' "$ROOT/scripts/codepilot-agent-runner.sh" + +if grep -Fq '"SUPABASE_ACCESS_TOKEN"' "$ROOT/scripts/install-phone-gateway-agent.sh"; then + echo "Gateway LaunchAgent must not load infrastructure access tokens." >&2 + exit 1 +fi + +python3 - "$ROOT/scripts/install-codepilot-local-agents.sh" <<'PY' +import re +import sys +from pathlib import Path + +source = Path(sys.argv[1]).read_text(encoding="utf-8") +if re.search(r"Umask\s*63", source) is None: + raise SystemExit("scheduler LaunchAgent must use an owner-only umask") +PY + +echo "LaunchAgent umask test passed." diff --git a/scripts/tests/privacy-audit-test.sh b/scripts/tests/privacy-audit-test.sh new file mode 100755 index 00000000..d31213ed --- /dev/null +++ b/scripts/tests/privacy-audit-test.sh @@ -0,0 +1,77 @@ +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +AUDIT="$ROOT/scripts/privacy-audit.sh" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +REPO="$TEST_ROOT/repo" +mkdir -p "$REPO/scripts" +cp "$AUDIT" "$REPO/scripts/privacy-audit.sh" +cd "$REPO" +git init -q +git config user.name "CodePilot Test" +git config user.email "codepilot-test@users.noreply.github.com" + +fail() { + echo "privacy audit regression failed: $1" >&2 + exit 1 +} + +write_fixture() { + print -r -- "$1" > public-fixture.txt + git add public-fixture.txt scripts/privacy-audit.sh +} + +assert_rejected_without_echo() { + local label="$1" + local fixture="$2" + local output + write_fixture "$fixture" + if output="$(CODEPILOT_PRIVACY_PATTERNS_FILE="$TEST_ROOT/patterns" zsh scripts/privacy-audit.sh 2>&1)"; then + fail "$label was accepted" + fi + [[ "$output" != *"$fixture"* ]] || fail "$label was printed" +} + +: > "$TEST_ROOT/patterns" +write_fixture $'Test iPhone\nhttps://ota.example.com/codexphone/manifest.plist\ncom.example.codepilot' +CODEPILOT_PRIVACY_PATTERNS_FILE="$TEST_ROOT/patterns" zsh scripts/privacy-audit.sh >/dev/null || fail "safe placeholders were rejected" + +assert_rejected_without_echo "absolute user path" "/Users/example/private-file" +assert_rejected_without_echo "absolute Linux home path" "/home/example/private-file" +assert_rejected_without_echo "absolute Windows user path" 'C:\Users\example\private-file' +assert_rejected_without_echo "email address" "person@example.net" +assert_rejected_without_echo "personal device label" "Alice's iPhone" +assert_rejected_without_echo "private bundle namespace" "com.person.codexphone" +assert_rejected_without_echo "non-placeholder OTA host" "https://ota.internal.invalid/build" +assert_rejected_without_echo "secret-looking value" "Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +assert_rejected_without_echo "GitHub OAuth token" "g""ho_0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "GitHub user token" "g""hu_0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "OpenAI project key" "s""k-proj-0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "Supabase access token" "sbp_0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "Supabase secret key" "sb_secret_0123456789abcdefghijklmnopqrstuvwxyz" +assert_rejected_without_echo "RSA private key" "-----BEGIN RSA PRIVATE KEY-----" +assert_rejected_without_echo "EC private key" "-----BEGIN EC PRIVATE KEY-----" +assert_rejected_without_echo "cloud access key" "AKIAABCDEFGHIJKLMNOP" +assert_rejected_without_echo "Google API key" "AI""za01234567890123456789012345678901234" +assert_rejected_without_echo "Slack token" "xox""b-1234567890-abcdefghijklmnop" +assert_rejected_without_echo "Slack app token" "xapp-1-0123456789-abcdefghijklmnop" +assert_rejected_without_echo "live payment key" "s""k_live_0123456789abcdefghijklmnop" +assert_rejected_without_echo "GitLab token" "glpat-0123456789abcdefghijklmnop" +assert_rejected_without_echo "npm token" "npm_0123456789abcdefghijklmnopqrstuv" +assert_rejected_without_echo "JWT" "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJleGFtcGxlIn0.abcdefghijklmnopqrstuvwxyz012345" + +write_fixture "Safe tracked content" +print -r -- "external-person@example.net" > "$TEST_ROOT/external-content" +external_output="" +if external_output="$(CODEPILOT_PRIVACY_PATTERNS_FILE="$TEST_ROOT/patterns" CODEPILOT_PRIVACY_EXTERNAL_FILE="$TEST_ROOT/external-content" zsh scripts/privacy-audit.sh 2>&1)"; then + fail "external private content was accepted" +fi +[[ "$external_output" != *"external-person@example.net"* ]] || fail "external private content was printed" + +print -r -- 'internal-marker-[0-9]+' > "$TEST_ROOT/patterns" +assert_rejected_without_echo "user-configured pattern" "internal-marker-1234" + +echo "privacy audit regression tests passed" diff --git a/scripts/tests/push-desktop-sync-security-test.sh b/scripts/tests/push-desktop-sync-security-test.sh new file mode 100755 index 00000000..db2b3a07 --- /dev/null +++ b/scripts/tests/push-desktop-sync-security-test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEST_ROOT="$(mktemp -d)" + +cleanup() { + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT + +cp "$ROOT/scripts/push-desktop-sync.sh" "$TEST_ROOT/push-desktop-sync.sh" +cp "$ROOT/scripts/import-desktop-sync.py" "$TEST_ROOT/import-desktop-sync.py" +cp "$ROOT/scripts/repair-remote-project-hosts.py" "$TEST_ROOT/repair-remote-project-hosts.py" + +cat > "$TEST_ROOT/export-desktop-sync.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: > "$CODEPILOT_EXPORT_MARKER" +bundle="$CODEPILOT_TEST_ROOT/codex-desktop-sync.tgz" +: > "$bundle" +printf '%s\n' "$bundle" +EOF + +mkdir "$TEST_ROOT/bin" +cat > "$TEST_ROOT/bin/ssh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'ssh' >> "$CODEPILOT_TRANSPORT_CAPTURE" +printf '\t%s' "$@" >> "$CODEPILOT_TRANSPORT_CAPTURE" +printf '\n' >> "$CODEPILOT_TRANSPORT_CAPTURE" +EOF +cat > "$TEST_ROOT/bin/scp" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'scp' >> "$CODEPILOT_TRANSPORT_CAPTURE" +printf '\t%s' "$@" >> "$CODEPILOT_TRANSPORT_CAPTURE" +printf '\n' >> "$CODEPILOT_TRANSPORT_CAPTURE" +EOF +chmod +x "$TEST_ROOT/push-desktop-sync.sh" "$TEST_ROOT/export-desktop-sync.sh" "$TEST_ROOT/bin/ssh" "$TEST_ROOT/bin/scp" + +export CODEPILOT_TEST_ROOT="$TEST_ROOT" +export CODEPILOT_EXPORT_MARKER="$TEST_ROOT/exported" +export CODEPILOT_TRANSPORT_CAPTURE="$TEST_ROOT/transport" +export PATH="$TEST_ROOT/bin:$PATH" + +for destination in '-oProxyCommand=unsafe' 'host:relative-path' 'host name' 'user@'; do + rm -f "$CODEPILOT_EXPORT_MARKER" + if "$TEST_ROOT/push-desktop-sync.sh" "$destination" >/dev/null 2>&1; then + echo "desktop sync accepted an unsafe SSH destination" >&2 + exit 1 + fi + if [[ -e "$CODEPILOT_EXPORT_MARKER" ]]; then + echo "desktop sync exported private metadata before validating the destination" >&2 + exit 1 + fi +done + +"$TEST_ROOT/push-desktop-sync.sh" 'tester@example-host' --host-id test-host >/dev/null + +[[ "$(wc -l < "$CODEPILOT_TRANSPORT_CAPTURE" | tr -d ' ')" == "5" ]] +if grep -Evq $'^(ssh|scp)\t--\t' "$CODEPILOT_TRANSPORT_CAPTURE"; then + echo "desktop sync transport omitted the SSH option terminator" >&2 + exit 1 +fi +grep -Fq $'ssh\t--\ttester@example-host\tmkdir -p ~/.codex-account-switcher/desktop-sync-import' "$CODEPILOT_TRANSPORT_CAPTURE" +grep -Fq $'scp\t--\t' "$CODEPILOT_TRANSPORT_CAPTURE" +grep -Fq $'ssh\t--\ttester@example-host\tcd ~ &&' "$CODEPILOT_TRANSPORT_CAPTURE" + +echo "Desktop sync transport security test passed." diff --git a/scripts/tests/test_desktop_sync_security.py b/scripts/tests/test_desktop_sync_security.py new file mode 100644 index 00000000..ba092509 --- /dev/null +++ b/scripts/tests/test_desktop_sync_security.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import importlib.util +import io +import json +import os +from pathlib import Path +import stat +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +IMPORT_SCRIPT = ROOT / "scripts" / "import-desktop-sync.py" +EXPORT_SCRIPT = ROOT / "scripts" / "export-desktop-sync.sh" + + +def load_import_module(): + spec = importlib.util.spec_from_file_location("import_desktop_sync", IMPORT_SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class DesktopSyncSecurityTests(unittest.TestCase): + def test_import_rejects_links_even_when_their_names_are_in_bounds(self): + importer = load_import_module() + for member_type in (tarfile.SYMTYPE, tarfile.LNKTYPE): + with self.subTest(member_type=member_type), tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + bundle = temporary_path / "bundle.tgz" + with tarfile.open(bundle, "w:gz") as archive: + member = tarfile.TarInfo("nested/link") + member.type = member_type + member.linkname = "../../outside" + archive.addfile(member) + + with self.assertRaisesRegex(RuntimeError, "non-file archive member"): + importer.safe_extract(bundle, temporary_path / "extract") + + def test_import_accepts_regular_manifest(self): + importer = load_import_module() + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + bundle = temporary_path / "bundle.tgz" + content = json.dumps({"kind": "codex-desktop-project-sync"}).encode() + with tarfile.open(bundle, "w:gz") as archive: + member = tarfile.TarInfo("manifest.json") + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + destination = temporary_path / "extract" + destination.mkdir() + importer.safe_extract(bundle, destination) + self.assertEqual((destination / "manifest.json").read_bytes(), content) + self.assertEqual(stat.S_IMODE((destination / "manifest.json").stat().st_mode), 0o600) + + def test_import_rejects_duplicate_file_members(self): + importer = load_import_module() + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + bundle = temporary_path / "bundle.tgz" + with tarfile.open(bundle, "w:gz") as archive: + for content in (b"first", b"second"): + member = tarfile.TarInfo("manifest.json") + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + destination = temporary_path / "extract" + destination.mkdir() + with self.assertRaises(FileExistsError): + importer.safe_extract(bundle, destination) + self.assertEqual((destination / "manifest.json").read_bytes(), b"first") + + def test_import_rejects_too_many_archive_members(self): + importer = load_import_module() + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + bundle = temporary_path / "bundle.tgz" + with tarfile.open(bundle, "w:gz") as archive: + for index in range(importer.MAX_ARCHIVE_MEMBERS + 1): + archive.addfile(tarfile.TarInfo(f"empty-{index}"), io.BytesIO()) + + with self.assertRaisesRegex(RuntimeError, "more than"): + importer.safe_extract(bundle, temporary_path / "extract") + + def test_import_rejects_excessive_declared_extract_size(self): + importer = load_import_module() + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + bundle = temporary_path / "bundle.tgz" + content = b"oversized" + with tarfile.open(bundle, "w:gz") as archive: + member = tarfile.TarInfo("oversized") + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + original_limit = importer.MAX_ARCHIVE_BYTES + importer.MAX_ARCHIVE_BYTES = len(content) - 1 + try: + with self.assertRaisesRegex(RuntimeError, "archive larger than"): + importer.safe_extract(bundle, temporary_path / "extract") + finally: + importer.MAX_ARCHIVE_BYTES = original_limit + + def test_export_bundle_is_owner_only(self): + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + codex_home = temporary_path / "codex" + output = temporary_path / "output" + codex_home.mkdir() + (codex_home / ".codex-global-state.json").write_text( + json.dumps({"active-workspace-roots": ["/workspace/example"]}), + encoding="utf-8", + ) + environment = os.environ.copy() + environment["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [str(EXPORT_SCRIPT), "--out-dir", str(output)], + check=True, + capture_output=True, + text=True, + env=environment, + ) + + bundle = Path(result.stdout.strip()) + self.assertEqual(stat.S_IMODE(bundle.stat().st_mode), 0o600) + + +if __name__ == "__main__": + unittest.main()