Full-access profile switch retains managed proxy but drops named-profile domain allowlist
Nobody has claimed this yet.
- 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
- Start a disposable app-server with temporary configuration and an ephemeral runtime. No inference turn is needed; a shell no-op materializes the runtime.
- Under the named profile, send
CONNECT github.com:443to that process's actual proxy listener: HTTP 200. - Call
thread/settings/updatewithpermissions: ":danger-full-access"and await the settings notification. Send the same CONNECT to the same listener: HTTP 403. - 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
- Session settings projection and network rebuild preserve existing filesystem denies and then rebuild network settings by the new active profile ID.
- Deny preservation and profile reconstruction turn disabled/unrestricted access with retained denies into managed root-write access.
- Built-in profile network selection returns default network configuration, which does not inherit the former named profile's domain map.
- Network spec reconstruction reenables the proxy for this resulting managed profile when the network-proxy feature is enabled.
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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