Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion internal/desktopapp/desktopapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,10 @@ func plutilValue(ctx context.Context, options Options, plist, key string) (strin
}

func installMacOS(ctx context.Context, options Options, replacePath string) (ActionResult, error) {
url := macDownloadURL(options)
url, err := approvedDownloadURL(macDownloadURL(options), "persistent.oaistatic.com")
if err != nil {
return ActionResult{}, fmt.Errorf("validate ChatGPT installer URL: %w", err)
}
tempDir, err := os.MkdirTemp("", "oneagent-desktop-agent-")
if err != nil {
return ActionResult{}, fmt.Errorf("create temporary installer directory: %w", err)
Expand Down Expand Up @@ -386,6 +389,9 @@ func installMacOS(ctx context.Context, options Options, replacePath string) (Act
if metadata.bundleID != CodexBundleID {
return ActionResult{}, fmt.Errorf("downloaded app has unexpected bundle identifier %q", metadata.bundleID)
}
if err := verifyMacOSApp(ctx, options, appPath); err != nil {
return ActionResult{}, fmt.Errorf("verify downloaded ChatGPT app: %w", err)
}
appName := filepath.Base(appPath)
destinations := make([]string, 0, 2)
if replacePath != "" {
Expand Down
93 changes: 93 additions & 0 deletions internal/desktopapp/desktopapp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,96 @@ func TestProfileAgentIDKeepsChatGPTOnCodexAndScopesOtherApps(t *testing.T) {
t.Fatal("whitespace around a non-shared desktop ID changed its ownership")
}
}

func TestVerifyMacOSAppRequiresExpectedTeamAndNotarization(t *testing.T) {
runner := &scriptedRunner{results: []process.Result{
{ExitCode: 0},
{
ExitCode: 0,
Stderr: strings.Join([]string{
"Identifier=com.openai.codex",
"Authority=Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)",
"TeamIdentifier=2DC432GLL2",
}, "\n"),
},
{ExitCode: 0, Stdout: "ChatGPT.app: accepted\nsource=Notarized Developer ID"},
}}

if err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app"); err != nil {
t.Fatalf("verifyMacOSApp() error = %v", err)
}
if len(runner.calls) != 3 {
t.Fatalf("verification calls = %#v, want codesign verify, codesign details, spctl", runner.calls)
}
}

func TestVerifyMacOSAppRejectsUnsignedBundleBeforeIdentityChecks(t *testing.T) {
runner := &scriptedRunner{results: []process.Result{{ExitCode: 1, Stderr: "code object is not signed at all"}}}

err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app")

if err == nil || !strings.Contains(err.Error(), "code signature") {
t.Fatalf("verifyMacOSApp() error = %v, want code-signature failure", err)
}
if len(runner.calls) != 1 {
t.Fatalf("verification continued after signature failure: %#v", runner.calls)
}
}

func TestApprovedDownloadURLRequiresHTTPSAndAllowedHost(t *testing.T) {
for _, test := range []struct {
name string
url string
want bool
}{
{name: "official", url: MacDownloadURL, want: true},
{name: "http", url: "http://persistent.oaistatic.com/codex-app-prod/ChatGPT.dmg", want: false},
{name: "wrong host", url: "https://example.test/ChatGPT.dmg", want: false},
} {
t.Run(test.name, func(t *testing.T) {
_, err := approvedDownloadURL(test.url, "persistent.oaistatic.com")
if (err == nil) != test.want {
t.Fatalf("approvedDownloadURL(%q) error = %v, want allowed=%v", test.url, err, test.want)
}
})
}
}

func TestVerifyMacOSAppRejectsWrongTeamID(t *testing.T) {
runner := &scriptedRunner{results: []process.Result{
{ExitCode: 0},
{
ExitCode: 0,
Stderr: "Identifier=com.openai.codex\nAuthority=Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)\nTeamIdentifier=WRONGTEAM",
},
}}

err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app")

if err == nil || !strings.Contains(err.Error(), "TeamIdentifier") {
t.Fatalf("verifyMacOSApp() error = %v, want TeamIdentifier failure", err)
}
if len(runner.calls) != 2 {
t.Fatalf("verification continued after identity failure: %#v", runner.calls)
}
}

func TestVerifyMacOSAppRejectsNonNotarizedDeveloperIDSource(t *testing.T) {
runner := &scriptedRunner{results: []process.Result{
{ExitCode: 0},
{
ExitCode: 0,
Stderr: "Identifier=com.openai.codex\nAuthority=Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)\nTeamIdentifier=2DC432GLL2",
},
{ExitCode: 0, Stdout: "ChatGPT.app: accepted\nsource=Developer ID"},
}}

err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app")

if err == nil || !strings.Contains(err.Error(), "notarized Developer ID") {
t.Fatalf("verifyMacOSApp() error = %v, want notarization failure", err)
}
if len(runner.calls) != 3 {
t.Fatalf("verification continued after Gatekeeper failure: %#v", runner.calls)
}
}
31 changes: 31 additions & 0 deletions internal/desktopapp/download_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package desktopapp

import (
"fmt"
"net/url"
"strings"
)

// approvedDownloadURL keeps the installer trust boundary on HTTPS endpoints
// owned by the vendor. Signature verification remains mandatory after download;
// an allowlisted host alone does not authenticate the bytes.
func approvedDownloadURL(raw string, allowedHosts ...string) (string, error) {
value := strings.TrimSpace(raw)
parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("parse download URL: %w", err)
}
if parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
return "", fmt.Errorf("download URL must be an HTTPS URL without credentials")
}
if port := parsed.Port(); port != "" && port != "443" {
return "", fmt.Errorf("download URL must use HTTPS port 443")
}
host := strings.ToLower(parsed.Hostname())
for _, allowed := range allowedHosts {
if host == strings.ToLower(strings.TrimSpace(allowed)) {
return parsed.String(), nil
}
}
return "", fmt.Errorf("download URL host %q is not approved", host)
}
55 changes: 55 additions & 0 deletions internal/desktopapp/macos_verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package desktopapp

import (
"context"
"errors"
"fmt"
"strings"
)

const (
MacExpectedTeamID = "2DC432GLL2"
MacExpectedAuthority = "Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)"
MacExpectedSpctlSource = "source=Notarized Developer ID"
)

func verifyMacOSApp(ctx context.Context, options Options, appPath string) error {
result, err := run(options, ctx, []string{"/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", appPath}, installTimeout)
if err != nil {
return fmt.Errorf("verify macOS code signature: %w", err)
}
if result.ExitCode != 0 {
return commandFailure("verify macOS code signature", result)
}

result, err = run(options, ctx, []string{"/usr/bin/codesign", "-dv", "--verbose=4", appPath}, installTimeout)
if err != nil {
return fmt.Errorf("read macOS signing identity: %w", err)
}
if result.ExitCode != 0 {
return commandFailure("read macOS signing identity", result)
}
details := result.Stdout + "\n" + result.Stderr
for _, required := range []string{
"Identifier=" + CodexBundleID,
"TeamIdentifier=" + MacExpectedTeamID,
"Authority=" + MacExpectedAuthority,
} {
if !strings.Contains(details, required) {
return fmt.Errorf("macOS signature is missing expected identity %q", required)
}
}

result, err = run(options, ctx, []string{"/usr/sbin/spctl", "--assess", "--type", "execute", "--verbose=4", appPath}, installTimeout)
if err != nil {
return fmt.Errorf("assess macOS app with Gatekeeper: %w", err)
}
if result.ExitCode != 0 {
return commandFailure("assess macOS app with Gatekeeper", result)
}
if !strings.Contains(result.Stdout+"\n"+result.Stderr, MacExpectedSpctlSource) {
return errors.New("macOS app is not accepted as notarized Developer ID software")
}

return nil
}
Loading