cdx-manager 0.24.4 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.6-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.4
1
+ 0.24.6
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.24.5
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch adds isolated workspace runs with opt-in network access. (#61)
6
+
7
+ ## Workspace runs
8
+
9
+ - Add `cdx run --isolate-workspace` for Linux workspace confinement through bubblewrap, exposing the checkout, permitted toolchains, shared caches, and provider authentication while keeping the operator's home and host processes outside the boundary. (#61)
10
+ - Add `--network-access` to enable Codex's workspace-write network setting while retaining its workspace sandbox. (#61)
11
+ - Preserve both options across detached runs and failover, and advertise `workspace_isolation` and `workspace_network` capabilities in `cdx schema`. (#61)
12
+
13
+ ## Maintenance
14
+
15
+ - Record tray companion checksums for v0.24.4. (`7465b2e8244e90032d1ab8b7ad7af6a26ffe805c`)
16
+ - Record release archive checksums for v0.24.4. (`a0afa21ccc816769fb0aac5883912efd90bf1028`)
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.24.6
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch gives isolated online workspace runs their own egress-only network. (#63)
6
+
7
+ ## Workspace runs
8
+
9
+ - Use a dedicated pasta network namespace for online `--isolate-workspace` runs, including provider transport, with port relays and host gateway mapping disabled. (#63)
10
+ - Apply nftables output filtering to reject loopback, private, link-local, and tailnet destination ranges, and use public DNS resolvers. (#63)
11
+ - Fail closed when pasta (passt), nftables, or Python 3 is unavailable, without falling back to the host network. (#63)
12
+
13
+ ## Maintenance
14
+
15
+ - Record tray companion checksums for v0.24.5. (`78ba34dd71a19ac392feda771737705a72c765e5`)
16
+ - Record release archive checksums for v0.24.5. (`f835adb7acc495133438c6e719fa452843109d58`)
@@ -617,6 +617,28 @@
617
617
  "x86_64-pc-windows-msvc": "6da838217c01251b809bac1e188217dcfbb00c2102b901cfda43cf2fc0696aa2",
618
618
  "x86_64-unknown-linux-musl": "c6651dd909d486c2b0bb209e0d813ab044dc7b5ffb1a07078c8ec7a69126e028"
619
619
  }
620
+ },
621
+ "v0.24.4": {
622
+ "github_tarball_sha256": "279d60a1073f56ac1ccc361248c14668e9e018d81dd56e4eb177cb3f4bac4cf6",
623
+ "github_zip_sha256": "05dd935dc0e8e686a1dce96c0543b91757995a5f49ae2aed1155ddf97916e55d",
624
+ "tray_assets": {
625
+ "aarch64-apple-darwin": "9fdcddf98e9723f2af2007f0c0a183644b2200e599c33d354c097e2510a3901b",
626
+ "aarch64-unknown-linux-musl": "eef61a80c0d1107de9dfb980fb5914d85ec3b0a5d640ff559bf8e7faa4e857bd",
627
+ "x86_64-apple-darwin": "3296ee96b17442d8a2d8eaee6ceeba90dc498c74168973a6932793fa509afc66",
628
+ "x86_64-pc-windows-msvc": "1fd621d4ba7175c868c4e598850c5f5ccd914c91aaef3d79024376082e8fe81d",
629
+ "x86_64-unknown-linux-musl": "f4c3a6e7c2af75cd8214609971fe2cbef6f34e818796d70b661d9a9f7e9327af"
630
+ }
631
+ },
632
+ "v0.24.5": {
633
+ "github_tarball_sha256": "0e34b1c67f3357d1551a2cce1187efb84cfb737163e91b3ae4e448f9bd2fee08",
634
+ "github_zip_sha256": "0f1242f234392f003fe6ed61206d4b931d72887f875cd0a60b20199a2f5745a6",
635
+ "tray_assets": {
636
+ "aarch64-apple-darwin": "6847d3f6b66137157896ec901b47a3b48913f78390b3fff50d1d59c85151e748",
637
+ "aarch64-unknown-linux-musl": "6f167515cdc0914b1d04ceee0ea60e28de57d2321d4073912409a6d9266c6185",
638
+ "x86_64-apple-darwin": "16a9bfc33fe03b771bdb7ffd417ce222785062f342c1c599bdda2a3fc7200841",
639
+ "x86_64-pc-windows-msvc": "74124b2e40a58a58b55724ef453c4b543a83eefab9a5f92685b239c5d97e7f67",
640
+ "x86_64-unknown-linux-musl": "089e552a4dfae4080a2e76cedb6fe72fc8013a9cd08cf3c8b333f916198da57c"
641
+ }
620
642
  }
621
643
  }
622
644
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.24.4",
3
+ "version": "0.24.6",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.24.4"
7
+ version = "0.24.6"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli_args.py CHANGED
@@ -47,7 +47,7 @@ CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
47
47
  SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
48
48
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
49
49
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
50
- RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
50
+ RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
51
51
  RUN_JSON_REQUIRED = "cdx run: --json is required."
52
52
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
53
53
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
@@ -475,6 +475,7 @@ def cdx_schema():
475
475
  # No `schema_version` here: the JSON envelope already carries it, and two
476
476
  # keys of that name in one payload would be one too many.
477
477
  return {
478
+ "capabilities": {"workspace_isolation": 1, "workspace_network": 1},
478
479
  "enums": {
479
480
  "permission": {
480
481
  "accepted": list(RUN_PERMISSION_VALUES),
@@ -665,6 +666,8 @@ def _parse_run_args(args):
665
666
  "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
666
667
  "--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_run_timeout_seconds},
667
668
  "--detach": {"key": "detach", "type": "bool", "default": False},
669
+ "--network-access": {"key": "network_access", "type": "bool", "default": False},
670
+ "--isolate-workspace": {"key": "isolate_workspace", "type": "bool", "default": False},
668
671
  "--failover": {"key": "failover", "type": "bool", "default": False},
669
672
  "--refresh": {"key": "refresh", "type": "bool", "default": False},
670
673
  "--label": {"key": "label", "type": "str", "default": None},
@@ -716,6 +719,8 @@ def _parse_run_args(args):
716
719
  "permission": parsed["permission"],
717
720
  "timeout_seconds": parsed["timeout_seconds"],
718
721
  "detach": parsed["detach"],
722
+ "network_access": parsed["network_access"],
723
+ "isolate_workspace": parsed["isolate_workspace"],
719
724
  "failover": parsed["failover"],
720
725
  "label": parsed["label"],
721
726
  "max_parallel": parsed["max_parallel"],
@@ -206,8 +206,9 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
206
206
  argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
207
207
  # The child is the supervisor: it watches the run like any blocking one, so
208
208
  # it applies the failover rule and records each move in the registry.
209
- if parsed.get("failover"):
210
- argv.append("--failover")
209
+ for key in ("network_access", "isolate_workspace", "failover"):
210
+ if parsed.get(key):
211
+ argv.append("--" + key.replace("_", "-"))
211
212
  argv.append("--json")
212
213
  return argv
213
214
 
@@ -572,7 +573,7 @@ def handle_run(rest, ctx):
572
573
  raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
573
574
  prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
574
575
  _consume_detached_prompt_file(parsed, detached_run_id)
575
- launch_updates = {}
576
+ launch_updates = {key: parsed.get(key, False) for key in ("network_access", "isolate_workspace")}
576
577
  if parsed.get("model"):
577
578
  launch_updates["model"] = parsed["model"]
578
579
  if parsed.get("permission"):
@@ -643,7 +644,7 @@ def handle_run(rest, ctx):
643
644
  launch = None
644
645
  # A provider's own background agent has no cdx process watching it,
645
646
  # so nothing could fail it over; --failover keeps the in-house child.
646
- if not parsed.get("failover") and supports_native_background(
647
+ if not parsed.get("isolate_workspace") and not parsed.get("network_access") and not parsed.get("failover") and supports_native_background(
647
648
  run_session.get("provider"),
648
649
  env=ctx.get("env"),
649
650
  spawn_sync=ctx.get("spawn_text"),
@@ -1210,6 +1210,33 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1210
1210
  env_override=env_override,
1211
1211
  initial_prompt=initial_prompt,
1212
1212
  )
1213
+ launch = session.get("launch") or {}
1214
+ if launch.get("network_access"):
1215
+ if session.get("provider") == PROVIDER_CODEX:
1216
+ # Keep the workspace sandbox; append overrides before the prompt separator.
1217
+ index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
1218
+ spec["args"][index:index] = ["-c", 'sandbox_workspace_write.network_access=true', '-c', 'sandbox_workspace_write.writable_roots=["/cache", "/home/agent"]']
1219
+ elif session.get("provider") != PROVIDER_CLAUDE:
1220
+ raise CdxError("Network override is unsupported for this provider")
1221
+ if launch.get("isolate_workspace"):
1222
+ from pathlib import Path
1223
+
1224
+ from .workspace_sandbox import sandbox_command
1225
+ if session.get("provider") == PROVIDER_CLAUDE:
1226
+ # The outer OS sandbox is the permission boundary for unattended Bash.
1227
+ index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
1228
+ spec["args"][index:index] = ["--permission-mode", "bypassPermissions"]
1229
+ try:
1230
+ argv, child_env = sandbox_command(
1231
+ [spec["command"], *spec["args"]], Path(cwd),
1232
+ network=launch.get("network_access", False),
1233
+ writable=launch.get("permission") != "review",
1234
+ source=spec["options"]["env"], auth_home=_get_auth_home(session),
1235
+ provider=session.get("provider"))
1236
+ except (OSError, RuntimeError) as error:
1237
+ raise CdxError(str(error)) from error
1238
+ spec.update(command=argv[0], args=argv[1:])
1239
+ spec["options"]["env"] = child_env
1213
1240
  paths = _headless_artifact_paths(session, run_id=run_id)
1214
1241
  start_time = datetime.now(timezone.utc)
1215
1242
  command = spec["command"]
@@ -133,7 +133,7 @@ def run_warnings(parsed, session, selection_row=None):
133
133
  })
134
134
  provider = session.get("provider") if session else parsed.get("provider")
135
135
  permission = run_effective_permission(parsed, session)
136
- if headless_permission_disables_network(provider, permission):
136
+ if not parsed.get("network_access") and headless_permission_disables_network(provider, permission):
137
137
  described = repr(permission) if permission else "its default (none set)"
138
138
  warnings.append({
139
139
  "code": "network_disabled_by_permission",
@@ -0,0 +1,145 @@
1
+ """Linux workspace confinement shared with the cdx-manager integration patch.
2
+
3
+ Expose tools, caches and one checkout, never the operator's home or host processes.
4
+ Provider authentication is the only credential deliberately passed to the provider.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ CACHE = Path.home() / ".cdx" / "shared-cache"
13
+
14
+ # Installed before any untrusted command, in the namespace owned by pasta.
15
+ NETWORK_RULES = """table inet mission_egress {
16
+ chain output {
17
+ type filter hook output priority 0; policy accept;
18
+ oif "lo" accept
19
+ ip daddr { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
20
+ 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16 } reject
21
+ ip6 daddr { ::1, fc00::/7, fe80::/10 } reject
22
+ }
23
+ }
24
+ """
25
+ PUBLIC_RESOLV_CONF = "nameserver 1.1.1.1\nnameserver 9.9.9.9\n"
26
+
27
+ # Keep the trusted setup outside the agent's filesystem/PID/user namespaces.
28
+ NETWORK_SETUP = """
29
+ import pathlib
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ try:
34
+ subprocess.run([sys.argv[1], "-f", "-"], input=RULES, text=True,
35
+ check=True, timeout=15)
36
+ with tempfile.TemporaryDirectory(prefix="mission-dns-") as directory:
37
+ resolver = pathlib.Path(directory) / "resolv.conf"
38
+ resolver.write_text(RESOLVER)
39
+ args = sys.argv[2:]
40
+ index = args.index("--")
41
+ args[index:index] = ["--ro-bind", str(resolver), "/etc/resolv.conf"]
42
+ status = subprocess.call(args)
43
+ except (OSError, subprocess.SubprocessError) as error:
44
+ print("Isolated mission network setup failed: " + str(error), file=sys.stderr)
45
+ sys.exit(1)
46
+ sys.exit(status)
47
+ """
48
+
49
+
50
+ def clean_env(source: dict) -> dict:
51
+ allowed = ("PATH", "LANG", "LC_ALL", "TERM", "TZ")
52
+ env = {key: source[key] for key in allowed if key in source}
53
+ env.update(HOME="/home/agent", XDG_CONFIG_HOME="/home/agent/.config",
54
+ XDG_CACHE_HOME="/cache", GIT_CONFIG_NOSYSTEM="1",
55
+ GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0", GIT_OPTIONAL_LOCKS="0",
56
+ GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="credential.helper", GIT_CONFIG_VALUE_0="",
57
+ GH_CONFIG_DIR="/home/agent/.config/gh", NPM_CONFIG_CACHE="/cache/npm",
58
+ PIP_CACHE_DIR="/cache/pip", UV_CACHE_DIR="/cache/uv",
59
+ PLAYWRIGHT_BROWSERS_PATH="/cache/playwright", CI="1")
60
+ return env
61
+
62
+
63
+ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable: bool = True,
64
+ source: dict | None = None, auth_home: str = "", provider: str = "") -> tuple[list[str], dict]:
65
+ source = os.environ if source is None else source
66
+ binary = shutil.which(command[0], path=source.get("PATH"))
67
+ bwrap = shutil.which("bwrap", path=source.get("PATH"))
68
+ if not binary or not bwrap:
69
+ raise RuntimeError("Workspace isolation requires bubblewrap and " + command[0])
70
+ if tree.is_symlink() or not tree.is_dir():
71
+ raise RuntimeError("Workspace must be a directory")
72
+ tree = tree.resolve()
73
+ CACHE.mkdir(parents=True, exist_ok=True)
74
+ # Provider transport uses the same restricted egress, even for read-only tools.
75
+ online = bool(network or provider)
76
+ if online:
77
+ pasta = shutil.which("pasta", path=source.get("PATH"))
78
+ nft = shutil.which("nft", path=os.defpath + ":/usr/sbin:/sbin")
79
+ python = shutil.which("python3", path=source.get("PATH"))
80
+ if not pasta or not nft or not python:
81
+ raise RuntimeError("Isolated mission networking requires pasta (passt), nftables and python3; no shared-network fallback")
82
+ args = [bwrap, "--die-with-parent", "--new-session"]
83
+ # pasta already created the network namespace. A new child user namespace and
84
+ # dropped capabilities prevent the agent from changing its ancestor's nft rules.
85
+ args += (["--unshare-user", "--unshare-ipc", "--unshare-pid", "--unshare-uts", "--unshare-cgroup"]
86
+ if online else ["--unshare-all"])
87
+ args += ["--cap-drop", "ALL"]
88
+ args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/home/agent"]
89
+ # An allowlist avoids overlooking another secret file or a credential symlink.
90
+ paths = [Path(p) for p in ("/usr", "/bin", "/sbin", "/lib", "/lib64")]
91
+ home = Path.home()
92
+ paths += [home / ".nvm", home / ".local/bin", home / ".local/share/claude",
93
+ home / ".local/share/uv"]
94
+ for path in paths:
95
+ if path.exists():
96
+ args += ["--ro-bind", str(path), str(path)]
97
+ for path in ("/etc/ssl", "/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf", "/etc/ld.so.cache",
98
+ "/etc/fonts", "/etc/passwd", "/etc/group", "/etc/alternatives"):
99
+ if Path(path).exists() and not (online and path == "/etc/resolv.conf"):
100
+ args += ["--ro-bind", path, path]
101
+ # Fail closed for a provider installed outside the explicitly permitted toolchains.
102
+ resolved = Path(binary).resolve()
103
+ if not any(resolved.is_relative_to(p.resolve()) for p in paths if p.exists()):
104
+ raise RuntimeError("Executable is outside the permitted toolchains: " + str(resolved))
105
+ args += ["--bind", str(CACHE), "/cache", "--bind" if writable else "--ro-bind", str(tree), str(tree)]
106
+ marker = tree / ".git"
107
+ if marker.is_file():
108
+ text = marker.read_text().strip()
109
+ if marker.is_symlink() or not text.startswith("gitdir: "):
110
+ raise RuntimeError("Invalid worktree Git marker")
111
+ gitdir = (tree / text.removeprefix("gitdir: ")).resolve()
112
+ common = (gitdir / (gitdir / "commondir").read_text().strip()).resolve()
113
+ # A writable marker must never turn into an arbitrary host-directory mount.
114
+ if (common.name != ".git" or gitdir.parent != common / "worktrees"
115
+ or common.is_relative_to(tree) or common.is_relative_to(CACHE.resolve())
116
+ or Path((gitdir / "gitdir").read_text().strip()).resolve() != marker):
117
+ raise RuntimeError("Invalid external worktree Git directory")
118
+ args += ["--ro-bind", str(marker), str(marker), "--ro-bind", str(common), str(common),
119
+ "--tmpfs", str(common / "worktrees"), "--ro-bind", str(gitdir), str(gitdir),
120
+ "--ro-bind", "/dev/null", str(common / "config"),
121
+ "--tmpfs", str(common / "hooks")]
122
+ if (gitdir / "config.worktree").exists():
123
+ args += ["--ro-bind", "/dev/null", str(gitdir / "config.worktree")]
124
+ elif marker.exists():
125
+ raise RuntimeError("Expected an isolated git worktree")
126
+ env = clean_env(source)
127
+ if provider == "codex":
128
+ env["CODEX_HOME"] = "/home/agent/.codex"
129
+ args += ["--ro-bind", str(Path(auth_home) / "auth.json"), "/home/agent/.codex/auth.json"]
130
+ elif provider == "claude":
131
+ token = source.get("CLAUDE_CODE_OAUTH_TOKEN")
132
+ if not token:
133
+ raise RuntimeError("Isolated Claude runs require a cdx OAuth setup token")
134
+ env.update(CLAUDE_CODE_OAUTH_TOKEN=token, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB="1",
135
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
136
+ elif provider:
137
+ raise RuntimeError("Unsupported isolated provider: " + provider)
138
+ args += ["--chdir", str(tree), "--", binary, *command[1:]]
139
+ if online:
140
+ setup = "RULES = " + repr(NETWORK_RULES) + "\nRESOLVER = " + repr(PUBLIC_RESOLV_CONF) + "\n" + NETWORK_SETUP
141
+ # Disable both directions explicitly: automatic host-loopback splicing
142
+ # bypasses output filtering unless -T/-U are also disabled.
143
+ args = [pasta, "--config-net", "--no-map-gw", "-t", "none", "-u", "none",
144
+ "-T", "none", "-U", "none", "-q", "--", python, "-I", "-c", setup, nft, *args]
145
+ return args, env