openai / openai/codex

Full-access profile switch retains managed proxy but drops named-profile domain allowlist

Open
#44,357 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app-server bug CLI sandbox
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

What version of Codex CLI is running?

Reproduced on 0.154.0, the latest stable release at filing, using the official macOS arm64 release binary. Also reproduced on 0.153.4. The 0.154.0 archive SHA-256 was verified against the release asset digest before execution.

What subscription do you have?

The isolated reproduction does not require a model request or subscription. It uses a local placeholder provider with authentication disabled.

Which model were you using?

No inference turn is requested. This is a local app-server/settings/proxy reproduction.

What platform is your computer?

Darwin 25.6.0 arm64 arm (uname -mprs), macOS arm64.

What terminal emulator and version are you using?

Reproduced through app-server stdio driven by Python, independently of an interactive terminal UI.

Codex doctor report

Not attached. The runnable reproduction below creates its own temporary configuration and ephemeral runtime instead of relying on the reporter's machine configuration. The fixture reads no saved profiles or credential files.

What issue are you seeing?

Switching from a named permission profile to built-in full access can break a previously allowed GitHub connection. Switching back restores it without restarting the runtime.

With network proxy enabled, a named profile containing one harmless temporary filesystem deny entry and github.com = "allow" produces this sequence through the same owned proxy listener:

Profile CONNECT github.com:443
Named governed HTTP 200
Switch to :danger-full-access HTTP 403
Restore governed HTTP 200

Approval remains on-request throughout, so an approval-policy change is not required to reproduce this. The direct proxy probe does not test the interactive approval flow.

Reproduction observed through the installed binary

  1. Start a disposable app-server with temporary configuration and an ephemeral runtime. No inference turn is needed; a shell no-op materializes the runtime.
  2. Under the named profile, send CONNECT github.com:443 to that process's actual proxy listener: HTTP 200.
  3. Call thread/settings/update with permissions: ":danger-full-access" and await the settings notification. Send the same CONNECT to the same listener: HTTP 403.
  4. Restore the named profile and await its notification. The same CONNECT succeeds: HTTP 200.

Approval remains on-request for all three states. The full-access notification exposes activePermissionProfile.id = ":danger-full-access" alongside sandboxPolicy.type = "workspaceWrite", root / writable, and network enabled. No existing task or saved configuration was changed.

The probe tests proxy authorization directly. A separate static-profile comparison reproduced Git's CONNECT 403 with no profile domain table and restored Git access by adding only the existing GitHub domain map. The original task's precise historical settings dispatch was not observed.

Source trace in rust-v0.153.4

Expected behavior and regression coverage

The transition should preserve filesystem deny protections and have explicit, consistent network semantics. Selecting full access should not silently replace functioning explicit destination authorization with a proxy that rejects those destinations.

A candidate fix is to retain the previous network authorization configuration specifically when a built-in full-access transition remains managed because deny entries survive. This is a design proposal, not an implemented or verified patch. It must continue to honor managed domain restrictions and must not cause unrelated named-profile switches to inherit permissions they intentionally remove.

A regression test should apply the real session update to a fixture with filesystem denies and a domain rule, check resulting enforcement and domain authorization, then restore the original profile. Cover managed domain overrides and a full-access transition without inherited denies separately.

Workaround verified in the disposable runtime

Retain or restore the named profile carrying the intended domain rules. Restoring it recovered CONNECT authorization without restarting that runtime. This does not establish a universal remedy for all Git write-authority failures.

Self-contained macOS reproduction

Requires Python 3.11+ and Codex on PATH (or pass the Codex binary as the first argument). Save as repro.py, then run python3 repro.py /path/to/codex from an ordinary terminal. It creates only a temporary fixture/configuration, starts its own app-server, and terminates that process on exit. It connects to GitHub only with a CONNECT request; it sends no repository credentials and no request inside the tunnel. It leaves the temporary diagnostic directory for inspection.

The script discovers the proxy's actual ephemeral loopback port from only its own process via lsof. On macOS, the configured port is not necessarily the active listener. It waits for the matching settings notifications before probing each state.

Runnable reproduction (used for the 0.154.0 result above)
import json
import os
import pathlib
import queue
import re
import socket
import subprocess
import tempfile
import threading
import time
import sys


def toml(value):
    if isinstance(value, dict):
        return '{' + ', '.join(json.dumps(k) + ' = ' + toml(v) for k, v in value.items()) + '}'
    if isinstance(value, bool):
        return str(value).lower()
    return json.dumps(value)


diagnostic_home = pathlib.Path(tempfile.mkdtemp(prefix='codex-profile-transition-', dir='/private/tmp'))
work = diagnostic_home / 'work'
work.mkdir()
fixture = work / 'denied-test-file'
fixture.write_text('Harmless diagnostic fixture.\n')
denies = {str(fixture): 'deny'}
domains = {'github.com': 'allow'}
with socket.socket() as reserved:
    reserved.bind(('127.0.0.1', 0))
    port = reserved.getsockname()[1]
config = {
    'model': 'diagnostic-no-inference',
    'model_provider': 'diagnostic',
    'default_permissions': 'governed',
    'approval_policy': 'on-request',
    'features': {'network_proxy': {'enabled': True, 'proxy_url': f'http://127.0.0.1:{port}', 'enable_socks5': False}},
    'permissions': {'governed': {'extends': ':workspace', 'filesystem': denies, 'network': {'enabled': True, 'domains': domains}}},
    'model_providers': {'diagnostic': {'name': 'No inference diagnostic', 'base_url': 'http://127.0.0.1:1/v1', 'wire_api': 'responses', 'requires_openai_auth': False}},
}
(diagnostic_home / 'config.toml').write_text('\n'.join(k + ' = ' + toml(v) for k, v in config.items()) + '\n')
child_env = os.environ.copy()
child_env['CODEX_HOME'] = str(diagnostic_home)
inbox = queue.Queue()
events = []
results = {'diagnostic_home': str(diagnostic_home), 'proxy_port': port, 'deny_count': len(denies), 'steps': []}
stderr = (diagnostic_home / 'stderr.log').open('w')
process = subprocess.Popen([sys.argv[1] if len(sys.argv) > 1 else 'codex', 'app-server', '--stdio'], cwd=work, env=child_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr, text=True)


def reader():
    for line in process.stdout:
        try:
            inbox.put(json.loads(line))
        except ValueError:
            pass


threading.Thread(target=reader, daemon=True).start()
counter = 0


def rpc(method, params):
    global counter
    counter += 1
    request_id = counter
    process.stdin.write(json.dumps({'id': request_id, 'method': method, 'params': params}) + '\n')
    process.stdin.flush()
    deadline = time.monotonic() + 35
    while time.monotonic() < deadline:
        event = inbox.get(timeout=max(0.1, deadline - time.monotonic()))
        if event.get('id') == request_id:
            if 'error' in event:
                raise RuntimeError(json.dumps(event['error']))
            return event.get('result')
        events.append(event)
    raise TimeoutError(method)


def probe(label):
    deadline = time.monotonic() + 12
    while True:
        try:
            listeners = subprocess.run(['/usr/sbin/lsof', '-a', '-p', str(process.pid), '-iTCP', '-sTCP:LISTEN', '-Pn'], capture_output=True, text=True, timeout=5)
            candidates = re.findall(r'TCP 127\.0\.0\.1:(\d+) \(LISTEN\)', listeners.stdout)
            if len(candidates) != 1:
                raise ConnectionRefusedError('Expected exactly one diagnostic process loopback listener')
            active_port = int(candidates[0])
            connection = socket.create_connection(('127.0.0.1', active_port), timeout=2)
            break
        except ConnectionRefusedError:
            if time.monotonic() >= deadline:
                listeners = subprocess.run(['/usr/sbin/lsof', '-a', '-p', str(process.pid), '-iTCP', '-sTCP:LISTEN', '-Pn'], capture_output=True, text=True, timeout=5)
                results['listeners'] = listeners.stdout
                raise
            time.sleep(0.1)
    with connection:
        connection.settimeout(8)
        connection.sendall(b'CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\n\r\n')
        response = connection.recv(4096).decode('utf-8', 'replace')
    result = {'label': label, 'http_status': response.splitlines()[0], 'actual_proxy_port': active_port}
    results['steps'].append(result)
    print(json.dumps(result), flush=True)


def wait_settings(profile):
    deadline = time.monotonic() + 20
    def matches(event):
        settings = event.get('params', {}).get('threadSettings', {})
        return event.get('method') == 'thread/settings/updated' and settings.get('activePermissionProfile', {}).get('id') == profile
    if any(matches(event) for event in events):
        return
    while time.monotonic() < deadline:
        event = inbox.get(timeout=max(0.1, deadline - time.monotonic()))
        events.append(event)
        if matches(event):
            return
    raise TimeoutError('settings notification ' + profile)


try:
    initialization = rpc('initialize', {'clientInfo': {'name': 'isolated_policy_diagnostic', 'version': '1'}, 'capabilities': {'experimentalApi': True, 'requestAttestation': False}})
    results['initialize'] = initialization
    process.stdin.write(json.dumps({'method': 'initialized'}) + '\n')
    process.stdin.flush()
    effective = rpc('config/read', {'cwd': str(work)})
    results['effective_config'] = {k: v for k, v in effective.get('config', {}).items() if k in ['features', 'permissions', 'default_permissions', 'sandbox_mode']}
    started = rpc('thread/start', {'cwd': str(work), 'permissions': 'governed', 'ephemeral': True, 'experimentalRawEvents': True})
    thread_id = started['thread']['id']
    results['start_policy'] = {k: started.get(k) for k in ['sandbox', 'activePermissionProfile', 'approvalPolicy']}
    rpc('thread/shellCommand', {'threadId': thread_id, 'command': '/usr/bin/true', 'timeoutMs': 1000})
    try:
        probe('governed_before')
    except ConnectionRefusedError:
        results['proxy_probe_unavailable'] = True
    rpc('thread/settings/update', {'threadId': thread_id, 'permissions': ':danger-full-access'})
    wait_settings(':danger-full-access')
    if not results.get('proxy_probe_unavailable'):
        probe('full_access_after_switch')
    rpc('thread/settings/update', {'threadId': thread_id, 'permissions': 'governed'})
    wait_settings('governed')
    if not results.get('proxy_probe_unavailable'):
        probe('governed_restored')
except Exception as error:
    results['error'] = str(error)
    print(json.dumps({'error': str(error)}), flush=True)
finally:
    process.terminate()
    try:
        process.wait(timeout=5)
    except subprocess.TimeoutExpired:
        process.kill()
        process.wait(timeout=5)
    stderr.close()
    while not inbox.empty():
        events.append(inbox.get_nowait())
    results['settings_events'] = [e for e in events if e.get('method') == 'thread/settings/updated']
    results['notification_methods'] = sorted({e.get('method', '') for e in events})
    (diagnostic_home / 'result.json').write_text(json.dumps(results, indent=2))
    print(json.dumps({'result_path': str(diagnostic_home / 'result.json')}), flush=True)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with codex-rs/core/src/session/session.rs, codex-rs/protocol/src/permissions.rs, codex-rs/protocol/src/models.rs, and codex-rs/core/src/config/permissions.rs and config/mod.rs. Use the supplied Python reproduction through app-server stdio and thread/settings/update to inspect the profile transition and proxy enforcement. Done means a regression test covers retained filesystem denies, domain authorization, restoration of the named profile, and a full-access transition without inherited denies.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, rust
Domain
backend, networking, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.