impel-cli 0.16.3 → 0.16.4

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
@@ -551,10 +551,11 @@ impel codex exec --json "review the current diff"
551
551
  switch their native profiles in place; the isolated launchers are the safer
552
552
  default when personal and Impel sessions should coexist.
553
553
 
554
- Every Impel-managed Claude configuration uses the same strict sandbox defaults:
554
+ Every Impel-managed Claude configuration uses the same strict sandbox defaults
555
+ with unrestricted outbound network access:
555
556
  the isolated `impel claude` profile, each tenant's Claude Desktop Code profile,
556
557
  the native profile while `impel use gateway claude` is active, and branded
557
- gateway-only CLI profiles. App config version 14 also pins managed Claude
558
+ gateway-only CLI profiles. App config version 15 also pins managed Claude
558
559
  Desktop profiles to third-party deployment mode, enables the 3P Chat tab so
559
560
  Code's "Back to Claude" route has a valid destination, and removes the unusable
560
561
  Claude.ai sign-in path. Existing tenant profiles refresh onto these policies
@@ -566,12 +567,16 @@ Turns **on** gateway mode for the given tool (default `all`). Requires `impel au
566
567
  first.
567
568
 
568
569
  **Claude Code** — merges into `~/.claude/settings.json` (creating it if needed,
569
- leaving permissions, hooks, other env vars, and custom sandbox filesystem/network
570
- rules untouched). Gateway mode enables Claude's strict auto-allow sandbox by
570
+ leaving existing permission rules, hooks, other env vars, and custom sandbox
571
+ filesystem/network rules intact). Gateway mode enables Claude's strict auto-allow sandbox by
571
572
  default: Bash runs inside the OS sandbox without routine prompts, the
572
573
  `dangerouslyDisableSandbox` escape hatch is disabled, and supported platforms
573
574
  fail closed if isolation cannot initialize. Native Windows keeps startup
574
575
  available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
576
+ A global `WebFetch(domain:*)` allow rule opens every outbound destination for Bash commands
577
+ while retaining filesystem isolation. Explicit deny rules remain authoritative.
578
+ On macOS, `enableWeakerNetworkIsolation` lets Go-based network tools reach the
579
+ system TLS trust service while remaining sandboxed.
575
580
 
576
581
  ```json
577
582
  {
@@ -579,12 +584,16 @@ available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
579
584
  "env": {
580
585
  "ANTHROPIC_BASE_URL": "https://gateway.useimpel.com/anthropic"
581
586
  },
587
+ "permissions": {
588
+ "allow": ["WebFetch(domain:*)"]
589
+ },
582
590
  "sandbox": {
583
591
  "enabled": true,
584
592
  "failIfUnavailable": true,
585
593
  "autoAllowBashIfSandboxed": true,
586
594
  "allowUnsandboxedCommands": false,
587
- "excludedCommands": []
595
+ "excludedCommands": [],
596
+ "enableWeakerNetworkIsolation": true
588
597
  }
589
598
  }
590
599
  ```
@@ -596,7 +605,9 @@ available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
596
605
  as Claude Code's `apiKeyHelper`), so we reuse `impel token` there too instead of
597
606
  writing the PAT into `config.toml` or `~/.codex/auth.json`. The block written
598
607
  (fenced by markers, so everything else in your `config.toml` — other
599
- `model_providers`, `mcp_servers`, sandbox policy — is left alone):
608
+ `model_providers`, `mcp_servers`, and unrelated sandbox policy — is left alone).
609
+ Codex defaults to the `workspace-write` filesystem sandbox with unrestricted
610
+ direct outbound access; its optional network proxy stays disabled:
600
611
 
601
612
  ```toml
602
613
  model_provider = "impel"
@@ -604,6 +615,9 @@ model_provider = "impel"
604
615
  # >>> impel-cli managed block (model_providers.impel) >>>
605
616
  # Generated by `impel use gateway codex`. Safe to re-run; do not hand-edit
606
617
  # the lines between the markers above/below, they'll be overwritten.
618
+ sandbox_mode = "workspace-write"
619
+ sandbox_workspace_write.network_access = true
620
+
607
621
  [model_providers.impel]
608
622
  name = "Impel Gateway"
609
623
  base_url = "https://gateway.useimpel.com/chatgpt_passthrough/backend-api/codex"
@@ -618,8 +632,9 @@ refresh_interval_ms = 300000
618
632
  ```
619
633
 
620
634
  Before overwriting anything, `impel` **backs up** what was there (any prior
621
- `apiKeyHelper`, `ANTHROPIC_BASE_URL`, Claude sandbox policy, and the prior Codex
622
- `model_provider` value) into `~/.config/impel/config.json`, so `impel use account`
635
+ `apiKeyHelper`, `ANTHROPIC_BASE_URL`, Claude sandbox/permission policy, and the
636
+ prior Codex `model_provider`, sandbox mode, and workspace network values) into
637
+ `~/.config/impel/config.json`, so `impel use account`
623
638
  can restore it exactly. If `config.toml` already has a `[model_providers.impel]` table that
624
639
  wasn't written by this tool, `impel` refuses to touch the file and tells you to
625
640
  resolve it manually rather than risk emitting invalid/duplicate TOML.
@@ -632,11 +647,14 @@ removes **only** the Impel-added bits, never your own config:
632
647
  - **Claude Code** — removes `apiKeyHelper` only if it's `"impel token"`, and
633
648
  `env.ANTHROPIC_BASE_URL` only if it equals `<gateway>/anthropic`. Then restores
634
649
  whatever those keys held before gateway mode (from the backup). If there was
635
- nothing before, they're deleted (and an emptied `env` is dropped).
650
+ nothing before, they're deleted (and an emptied `env` is dropped). It also
651
+ removes only the Impel-added global `WebFetch(domain:*)` allow rule.
636
652
  - **Codex** — removes the marker-fenced `[model_providers.impel]` block, and
637
653
  resets `model_provider` **only if it currently reads `"impel"`** — back to its
638
654
  backed-up prior value, or, if there was none, removes the line entirely so
639
- Codex falls back to its built-in default. All other tables are untouched.
655
+ Codex falls back to its built-in default. The prior
656
+ `sandbox_mode` and `sandbox_workspace_write.network_access` values are
657
+ restored. All other tables are untouched.
640
658
 
641
659
  `impel use account` doesn't require the PAT and is safe to run repeatedly (if a
642
660
  tool is already in account mode it just says so and changes nothing).
@@ -929,8 +947,10 @@ impel status # see where each tool stands right now
929
947
  ```
930
948
 
931
949
  How the clean revert is guaranteed: `impel on` records the pre-existing values
932
- (your own `apiKeyHelper` / `ANTHROPIC_BASE_URL` / Claude sandbox policy, and Codex's prior
933
- `model_provider`) into `~/.config/impel/config.json` **before** overwriting them,
950
+ (your own `apiKeyHelper` / `ANTHROPIC_BASE_URL` / Claude sandbox and permission
951
+ policy, and Codex's prior `model_provider`, sandbox mode, and workspace network
952
+ value) into
953
+ `~/.config/impel/config.json` **before** overwriting them,
934
954
  and only ever captures a real *account-mode* value (re-running `impel on` never
935
955
  clobbers the backup with Impel's own values). `impel off` removes only keys that
936
956
  still point at Impel and restores those backups — so a full `on` → `off`
@@ -939,9 +959,10 @@ they started, with all your unrelated keys and tables intact.
939
959
 
940
960
  If you'd rather revert by hand: delete `apiKeyHelper`, the Impel
941
961
  `ANTHROPIC_BASE_URL`, and the Impel-managed sandbox keys from
942
- `~/.claude/settings.json`; delete the block between the
962
+ `~/.claude/settings.json`, plus the Impel-added `WebFetch(domain:*)` allow rule; delete the block between the
943
963
  `# >>> impel-cli managed block ... >>>` / `# <<< impel-cli managed block <<<`
944
- markers in `~/.codex/config.toml` and reset `model_provider`; and
964
+ markers in `~/.codex/config.toml`, reset `model_provider`, and restore or remove
965
+ `sandbox_mode` and `sandbox_workspace_write.network_access`; and
945
966
  `rm -rf ~/.config/impel` to forget the PAT + gateway entirely.
946
967
 
947
968
  ## Codex compatibility route
@@ -980,9 +1001,9 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
980
1001
  `impel token` rather than copying it into their own config files — one token,
981
1002
  one rotation path, no stale copies.
982
1003
  - **The `config.toml` merge is intentionally narrow**, not a general TOML
983
- parser: it only ever touches a single top-level `model_provider` line and its
984
- own fenced `[model_providers.impel*]` block, leaving unrelated tables (other
985
- providers, MCP servers, sandbox policy, etc.) untouched.
1004
+ parser: it touches the top-level `model_provider`, sandbox mode, the
1005
+ workspace-write network toggle, and its own fenced `[model_providers.impel*]` block, leaving unrelated
1006
+ tables (other providers, MCP servers, sandbox policy, etc.) untouched.
986
1007
 
987
1008
  ## Sources consulted for the Codex config
988
1009
 
@@ -995,7 +1016,7 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
995
1016
 
996
1017
  - https://code.claude.com/docs/en/settings#sandbox-settings
997
1018
  - https://code.claude.com/docs/en/sandboxing#configure-the-sandbox-for-your-organization
998
- - https://github.com/anthropics/claude-code/blob/v2.1.205/examples/settings/settings-bash-sandbox.json
1019
+ - https://github.com/anthropics/claude-code/blob/v2.1.214/examples/settings/settings-bash-sandbox.json
999
1020
 
1000
1021
  ## Sources consulted for concurrent tenant apps
1001
1022
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -163,7 +163,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
163
163
 
164
164
  // Bump when the written config/manifest schema changes; a mismatch forces the
165
165
  // slow open path (and thus a full config rewrite) after a CLI update.
166
- export const CURRENT_CONFIG_VERSION = 14;
166
+ export const CURRENT_CONFIG_VERSION = 15;
167
167
 
168
168
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
169
169
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -4,8 +4,11 @@ const MANAGED_SANDBOX_KEYS = Object.freeze([
4
4
  "autoAllowBashIfSandboxed",
5
5
  "allowUnsandboxedCommands",
6
6
  "excludedCommands",
7
+ "enableWeakerNetworkIsolation",
7
8
  ]);
8
9
 
10
+ export const IMPEL_CLAUDE_NETWORK_PERMISSION = "WebFetch(domain:*)";
11
+
9
12
  function isJsonObject(value) {
10
13
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
11
14
  }
@@ -28,24 +31,79 @@ export function impelClaudeSandboxPolicy(platform = process.platform) {
28
31
  autoAllowBashIfSandboxed: true,
29
32
  allowUnsandboxedCommands: false,
30
33
  excludedCommands: [],
34
+ ...(platform === "darwin" ? { enableWeakerNetworkIsolation: true } : {}),
35
+ };
36
+ }
37
+
38
+ function applyImpelClaudeNetworkPermission(permissions) {
39
+ const current = isJsonObject(permissions) ? permissions : {};
40
+ const allow = Array.isArray(current.allow) ? current.allow : [];
41
+ return {
42
+ ...current,
43
+ allow: [...new Set([...allow, IMPEL_CLAUDE_NETWORK_PERMISSION])],
31
44
  };
32
45
  }
33
46
 
34
47
  /** Apply the managed sandbox keys while preserving unrelated sandbox config. */
35
48
  export function applyImpelClaudeSandbox(settings, platform = process.platform) {
36
49
  const current = isJsonObject(settings.sandbox) ? settings.sandbox : {};
50
+ const policy = impelClaudeSandboxPolicy(platform);
37
51
  settings.sandbox = {
38
52
  ...current,
39
- ...impelClaudeSandboxPolicy(platform),
53
+ ...policy,
40
54
  };
55
+ settings.permissions = applyImpelClaudeNetworkPermission(settings.permissions);
41
56
  return settings.sandbox;
42
57
  }
43
58
 
44
59
  /** Capture the exact pre-Impel value for reversible native-profile switching. */
45
60
  export function captureClaudeSandbox(settings) {
46
- return Object.hasOwn(settings, "sandbox")
61
+ const snapshot = Object.hasOwn(settings, "sandbox")
47
62
  ? { present: true, value: settings.sandbox }
48
63
  : { present: false };
64
+ snapshot.permissions = Object.hasOwn(settings, "permissions")
65
+ ? { present: true, value: settings.permissions }
66
+ : { present: false };
67
+ return snapshot;
68
+ }
69
+
70
+ function restoreClaudeNetworkPermission(settings, snapshot) {
71
+ if (!Object.hasOwn(settings, "permissions")) return false;
72
+ const priorSnapshot = snapshot?.permissions;
73
+ const priorPresent = priorSnapshot?.present === true;
74
+ const priorValue = priorPresent ? priorSnapshot.value : undefined;
75
+ const appliedPrior = applyImpelClaudeNetworkPermission(priorValue);
76
+
77
+ if (sameJsonValue(settings.permissions, appliedPrior)) {
78
+ if (priorPresent) settings.permissions = priorValue;
79
+ else delete settings.permissions;
80
+ return true;
81
+ }
82
+ if (!isJsonObject(settings.permissions) || !Array.isArray(settings.permissions.allow)) {
83
+ return false;
84
+ }
85
+
86
+ const priorPermissions = isJsonObject(priorValue) ? priorValue : null;
87
+ const priorAllow = priorPermissions && Array.isArray(priorPermissions.allow)
88
+ ? priorPermissions.allow
89
+ : [];
90
+ if (priorAllow.includes(IMPEL_CLAUDE_NETWORK_PERMISSION)) return false;
91
+
92
+ const allow = settings.permissions.allow.filter(
93
+ (rule) => rule !== IMPEL_CLAUDE_NETWORK_PERMISSION,
94
+ );
95
+ if (sameJsonValue(allow, settings.permissions.allow)) return false;
96
+
97
+ const permissions = { ...settings.permissions };
98
+ if (allow.length > 0 || (priorPermissions && Object.hasOwn(priorPermissions, "allow"))) {
99
+ permissions.allow = allow;
100
+ } else {
101
+ delete permissions.allow;
102
+ }
103
+ if (Object.keys(permissions).length > 0) settings.permissions = permissions;
104
+ else if (priorPresent) settings.permissions = priorValue;
105
+ else delete settings.permissions;
106
+ return true;
49
107
  }
50
108
 
51
109
  /**
@@ -53,28 +111,33 @@ export function captureClaudeSandbox(settings) {
53
111
  * while gateway mode was active, plus unrelated filesystem/network keys, win.
54
112
  */
55
113
  export function restoreClaudeSandbox(settings, snapshot, platform = process.platform) {
56
- if (!isJsonObject(settings.sandbox)) return false;
57
114
  const policy = impelClaudeSandboxPolicy(platform);
58
115
  const prior = snapshot?.present === true && isJsonObject(snapshot.value)
59
116
  ? snapshot.value
60
117
  : null;
61
- const sandbox = { ...settings.sandbox };
62
118
  let changed = false;
63
119
 
64
- for (const key of MANAGED_SANDBOX_KEYS) {
65
- if (!Object.hasOwn(sandbox, key) || !sameJsonValue(sandbox[key], policy[key])) continue;
66
- if (prior && Object.hasOwn(prior, key)) sandbox[key] = prior[key];
67
- else delete sandbox[key];
68
- changed = true;
69
- }
120
+ if (isJsonObject(settings.sandbox)) {
121
+ const sandbox = { ...settings.sandbox };
122
+ let sandboxChanged = false;
123
+ for (const key of MANAGED_SANDBOX_KEYS) {
124
+ if (!Object.hasOwn(sandbox, key) || !sameJsonValue(sandbox[key], policy[key])) continue;
125
+ if (prior && Object.hasOwn(prior, key)) sandbox[key] = prior[key];
126
+ else delete sandbox[key];
127
+ sandboxChanged = true;
128
+ }
70
129
 
71
- if (!changed) return false;
72
- if (Object.keys(sandbox).length > 0 || (snapshot?.present === true && prior)) {
73
- settings.sandbox = sandbox;
74
- } else if (snapshot?.present === true) {
75
- settings.sandbox = snapshot.value;
76
- } else {
77
- delete settings.sandbox;
130
+ if (sandboxChanged) {
131
+ if (Object.keys(sandbox).length > 0 || (snapshot?.present === true && prior)) {
132
+ settings.sandbox = sandbox;
133
+ } else if (snapshot?.present === true) {
134
+ settings.sandbox = snapshot.value;
135
+ } else {
136
+ delete settings.sandbox;
137
+ }
138
+ changed = true;
139
+ }
78
140
  }
79
- return true;
141
+
142
+ return restoreClaudeNetworkPermission(settings, snapshot) || changed;
80
143
  }
@@ -80,19 +80,61 @@ function upsertManagedBoolean(toml, table, key, value, configPath) {
80
80
  return `${base}${base ? "\n\n" : ""}${MANAGED_COMMENT}\n[${table}]\n${key} = ${value}\n`;
81
81
  }
82
82
 
83
+ function upsertManagedRootString(toml, key, value, configPath) {
84
+ const lines = String(toml || "").replace(/\r\n/gu, "\n").split("\n");
85
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
86
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
87
+ const pattern = new RegExp(`^(\\s*)${escapeRegex(key)}\\s*=.*$`, "u");
88
+ const indices = lines
89
+ .slice(0, preambleEnd)
90
+ .flatMap((line, index) => (pattern.test(line) ? [index] : []));
91
+ if (indices.length > 1) {
92
+ throw new Error(`${configPath} defines ${key} more than once; resolve it before re-running.`);
93
+ }
94
+ if (indices.length === 1) {
95
+ const index = indices[0];
96
+ const indent = lines[index].match(pattern)?.[1] || "";
97
+ lines[index] = `${indent}${key} = ${JSON.stringify(value)}`;
98
+ } else {
99
+ lines.splice(preambleEnd, 0, `${key} = ${JSON.stringify(value)}`, "");
100
+ }
101
+ return `${lines.join("\n").replace(/\s*$/u, "")}\n`;
102
+ }
103
+
104
+ /** Enable unrestricted outbound access while Codex remains in workspace-write. */
105
+ export function enableManagedCodexNetworkAccess(
106
+ toml,
107
+ configPath = "managed Codex config",
108
+ ) {
109
+ const workspaceSandbox = upsertManagedRootString(
110
+ toml,
111
+ "sandbox_mode",
112
+ "workspace-write",
113
+ configPath,
114
+ );
115
+ return upsertManagedBoolean(
116
+ workspaceSandbox,
117
+ "sandbox_workspace_write",
118
+ "network_access",
119
+ "true",
120
+ configPath,
121
+ );
122
+ }
123
+
83
124
  /**
84
125
  * Disable Codex shell snapshots and keep its default KEY/SECRET/TOKEN filter
85
126
  * enabled. Existing unrelated keys in either table are retained.
86
127
  */
87
128
  export function hardenManagedCodexToml(toml, configPath = "managed Codex config") {
88
129
  const withoutSnapshots = upsertManagedBoolean(toml, "features", "shell_snapshot", "false", configPath);
89
- return upsertManagedBoolean(
130
+ const withoutCredentialSnapshots = upsertManagedBoolean(
90
131
  withoutSnapshots,
91
132
  "shell_environment_policy",
92
133
  "ignore_default_excludes",
93
134
  "false",
94
135
  configPath,
95
136
  );
137
+ return enableManagedCodexNetworkAccess(withoutCredentialSnapshots, configPath);
96
138
  }
97
139
 
98
140
  function assertSafeManagedPath(target, expectedType) {
package/src/codexSetup.js CHANGED
@@ -49,8 +49,17 @@ const START_MARK = `# >>> impel-cli managed block (model_providers.${PROVIDER_ID
49
49
  const END_MARK = `# <<< impel-cli managed block <<<`;
50
50
 
51
51
  const PROVIDER_LINE_RE = /^model_provider[ \t]*=[ \t]*"([^"]*)"[ \t]*$/m;
52
+ const NETWORK_TABLE = "sandbox_workspace_write";
53
+ const NETWORK_KEY = "network_access";
54
+ const NETWORK_DOTTED_RE = /^(\s*)sandbox_workspace_write\.network_access\s*=.*$/u;
55
+ const NETWORK_HEADER_RE = /^\s*\[sandbox_workspace_write\]\s*(?:#.*)?$/u;
56
+ const NETWORK_KEY_RE = /^(\s*)network_access\s*=.*$/u;
57
+ const SANDBOX_MODE_RE = /^(\s*)sandbox_mode\s*=.*$/u;
52
58
 
53
- function providerTablesBlock(gatewayUrl) {
59
+ function providerTablesBlock(
60
+ gatewayUrl,
61
+ { includeNetworkAccess = false, includeSandboxMode = false } = {},
62
+ ) {
54
63
  const baseUrl = impelCodexBaseUrl(gatewayUrl);
55
64
  const auth = impelCliInvocation(["token"]);
56
65
  const mcp = impelCliInvocation(["mcp"]);
@@ -58,6 +67,8 @@ function providerTablesBlock(gatewayUrl) {
58
67
  START_MARK,
59
68
  "# Generated by `impel use gateway codex`. Safe to re-run; do not hand-edit",
60
69
  "# the lines between the markers above/below, they'll be overwritten.",
70
+ ...(includeSandboxMode ? ['sandbox_mode = "workspace-write"'] : []),
71
+ ...(includeNetworkAccess ? [`${NETWORK_TABLE}.${NETWORK_KEY} = true`, ""] : []),
61
72
  `[model_providers.${PROVIDER_ID}]`,
62
73
  `name = "Impel Gateway"`,
63
74
  `base_url = "${baseUrl}"`,
@@ -76,6 +87,123 @@ function providerTablesBlock(gatewayUrl) {
76
87
  ].join("\n");
77
88
  }
78
89
 
90
+ function applyCodexSandboxMode(text, configPath = CODEX_CONFIG_PATH) {
91
+ const lines = String(text || "").replace(/\r\n/gu, "\n").split("\n");
92
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
93
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
94
+ const indices = lines
95
+ .slice(0, preambleEnd)
96
+ .flatMap((line, index) => (SANDBOX_MODE_RE.test(line) ? [index] : []));
97
+ if (indices.length > 1) {
98
+ throw new Error(`${configPath} defines sandbox_mode more than once; resolve it before re-running.`);
99
+ }
100
+ if (indices.length === 0) {
101
+ return { text, prior: { kind: "absent" }, includeInBlock: true };
102
+ }
103
+ const index = indices[0];
104
+ const line = lines[index];
105
+ const indent = line.match(SANDBOX_MODE_RE)?.[1] || "";
106
+ lines[index] = `${indent}sandbox_mode = "workspace-write"`;
107
+ return {
108
+ text: lines.join("\n"),
109
+ prior: { kind: "root", line },
110
+ includeInBlock: false,
111
+ };
112
+ }
113
+
114
+ function restoreCodexSandboxMode(text, snapshot) {
115
+ if (!snapshot || snapshot.kind === "absent") return { text, changed: false };
116
+ const lines = String(text || "").replace(/\r\n/gu, "\n").split("\n");
117
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
118
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
119
+ const indices = lines
120
+ .slice(0, preambleEnd)
121
+ .flatMap((line, index) => (SANDBOX_MODE_RE.test(line) ? [index] : []));
122
+ if (indices.length !== 1 || !/^\s*sandbox_mode\s*=\s*"workspace-write"\s*(?:#.*)?$/u.test(lines[indices[0]])) {
123
+ return { text, changed: false };
124
+ }
125
+ lines[indices[0]] = snapshot.line;
126
+ return { text: lines.join("\n"), changed: true };
127
+ }
128
+
129
+ function locateCodexNetworkAccess(text, configPath = CODEX_CONFIG_PATH) {
130
+ const lines = String(text || "").replace(/\r\n/gu, "\n").split("\n");
131
+ const firstTable = lines.findIndex((line) => /^\s*\[/u.test(line));
132
+ const preambleEnd = firstTable === -1 ? lines.length : firstTable;
133
+ const dotted = lines
134
+ .slice(0, preambleEnd)
135
+ .flatMap((line, index) => (NETWORK_DOTTED_RE.test(line) ? [index] : []));
136
+ const headers = lines.flatMap((line, index) => (NETWORK_HEADER_RE.test(line) ? [index] : []));
137
+ if (dotted.length > 1 || headers.length > 1 || (dotted.length && headers.length)) {
138
+ throw new Error(`${configPath} defines ${NETWORK_TABLE}.${NETWORK_KEY} more than once; resolve it before re-running.`);
139
+ }
140
+ if (dotted.length === 1) {
141
+ const index = dotted[0];
142
+ return { lines, kind: "dotted", index, line: lines[index] };
143
+ }
144
+ if (headers.length === 1) {
145
+ const header = headers[0];
146
+ const nextTableOffset = lines.slice(header + 1).findIndex((line) => /^\s*\[/u.test(line));
147
+ const end = nextTableOffset === -1 ? lines.length : header + 1 + nextTableOffset;
148
+ const keys = lines
149
+ .slice(header + 1, end)
150
+ .flatMap((line, index) => (NETWORK_KEY_RE.test(line) ? [header + 1 + index] : []));
151
+ if (keys.length > 1) {
152
+ throw new Error(`${configPath} defines ${NETWORK_KEY} more than once in [${NETWORK_TABLE}].`);
153
+ }
154
+ if (keys.length === 1) {
155
+ const index = keys[0];
156
+ return { lines, kind: "table", index, line: lines[index] };
157
+ }
158
+ return { lines, kind: "table-missing", header };
159
+ }
160
+ if (lines.slice(0, preambleEnd).some((line) => /^\s*sandbox_workspace_write\s*=/u.test(line))) {
161
+ throw new Error(`${configPath} defines ${NETWORK_TABLE} as an inline table; convert it to [${NETWORK_TABLE}] before re-running.`);
162
+ }
163
+ return { lines, kind: "absent" };
164
+ }
165
+
166
+ function applyCodexNetworkAccess(text, configPath = CODEX_CONFIG_PATH) {
167
+ const state = locateCodexNetworkAccess(text, configPath);
168
+ if (state.kind === "absent") {
169
+ return { text, prior: { kind: "absent" }, includeInBlock: true };
170
+ }
171
+ if (state.kind === "table-missing") {
172
+ state.lines.splice(state.header + 1, 0, `${NETWORK_KEY} = true`);
173
+ return {
174
+ text: state.lines.join("\n"),
175
+ prior: { kind: "table-missing" },
176
+ includeInBlock: false,
177
+ };
178
+ }
179
+ const indent = state.line.match(state.kind === "dotted" ? NETWORK_DOTTED_RE : NETWORK_KEY_RE)?.[1] || "";
180
+ state.lines[state.index] = state.kind === "dotted"
181
+ ? `${indent}${NETWORK_TABLE}.${NETWORK_KEY} = true`
182
+ : `${indent}${NETWORK_KEY} = true`;
183
+ return {
184
+ text: state.lines.join("\n"),
185
+ prior: { kind: state.kind, line: state.line },
186
+ includeInBlock: false,
187
+ };
188
+ }
189
+
190
+ function restoreCodexNetworkAccess(text, snapshot, configPath = CODEX_CONFIG_PATH) {
191
+ if (!snapshot || snapshot.kind === "absent") return { text, changed: false };
192
+ const state = locateCodexNetworkAccess(text, configPath);
193
+ if (!["dotted", "table"].includes(state.kind) || !/=\s*true\s*(?:#.*)?$/u.test(state.line)) {
194
+ return { text, changed: false };
195
+ }
196
+ if (snapshot.kind === "table-missing" && state.kind === "table") {
197
+ state.lines.splice(state.index, 1);
198
+ return { text: state.lines.join("\n"), changed: true };
199
+ }
200
+ if (snapshot.kind !== state.kind || typeof snapshot.line !== "string") {
201
+ return { text, changed: false };
202
+ }
203
+ state.lines[state.index] = snapshot.line;
204
+ return { text: state.lines.join("\n"), changed: true };
205
+ }
206
+
79
207
  function readConfig() {
80
208
  const exists = fs.existsSync(CODEX_CONFIG_PATH);
81
209
  const text = exists ? fs.readFileSync(CODEX_CONFIG_PATH, "utf8") : "";
@@ -155,7 +283,10 @@ export function applyCodexGateway(gatewayUrl) {
155
283
  );
156
284
  }
157
285
 
158
- const { preamble, rest } = splitPreamble(withoutOurBlock);
286
+ const sandboxMode = applyCodexSandboxMode(withoutOurBlock);
287
+ const networkAccess = applyCodexNetworkAccess(sandboxMode.text);
288
+
289
+ const { preamble, rest } = splitPreamble(networkAccess.text);
159
290
  const priorProviderValue = preamble.match(PROVIDER_LINE_RE)?.[1] ?? null;
160
291
  const newProviderLine = `model_provider = "${PROVIDER_ID}"`;
161
292
 
@@ -167,7 +298,10 @@ export function applyCodexGateway(gatewayUrl) {
167
298
  newPreamble = trimmed ? `${trimmed}\n${newProviderLine}\n` : `${newProviderLine}\n`;
168
299
  }
169
300
 
170
- const block = providerTablesBlock(gatewayUrl);
301
+ const block = providerTablesBlock(gatewayUrl, {
302
+ includeNetworkAccess: networkAccess.includeInBlock,
303
+ includeSandboxMode: sandboxMode.includeInBlock,
304
+ });
171
305
  const restTrimmed = rest.trim();
172
306
  const newText = `${newPreamble.trimEnd()}\n\n${block}\n` + (restTrimmed ? `\n${restTrimmed}\n` : "");
173
307
 
@@ -177,6 +311,8 @@ export function applyCodexGateway(gatewayUrl) {
177
311
  path: CODEX_CONFIG_PATH,
178
312
  baseUrl: impelCodexBaseUrl(gatewayUrl),
179
313
  priorProviderValue,
314
+ priorNetworkAccess: networkAccess.prior,
315
+ priorSandboxMode: sandboxMode.prior,
180
316
  };
181
317
  }
182
318
 
@@ -196,6 +332,11 @@ export function revertCodexGateway(backup = {}) {
196
332
  const removedBlock = original.includes(START_MARK);
197
333
  let text = stripManagedBlock(original);
198
334
 
335
+ const networkAccess = restoreCodexNetworkAccess(text, backup.network_access);
336
+ text = networkAccess.text;
337
+ const sandboxMode = restoreCodexSandboxMode(text, backup.sandbox_mode);
338
+ text = sandboxMode.text;
339
+
199
340
  let resetProvider = false;
200
341
  let restoredProvider = null;
201
342
  if (currentProviderValue(text) === PROVIDER_ID) {
@@ -220,5 +361,7 @@ export function revertCodexGateway(backup = {}) {
220
361
  removedBlock,
221
362
  resetProvider,
222
363
  restoredProvider,
364
+ restoredNetworkAccess: networkAccess.changed,
365
+ restoredSandboxMode: sandboxMode.changed,
223
366
  };
224
367
  }
@@ -78,6 +78,11 @@ async function useGateway({ target, app }) {
78
78
  }
79
79
  if (!Object.hasOwn(backup, "sandbox")) {
80
80
  backup.sandbox = result.priorSandbox;
81
+ } else if (
82
+ !Object.hasOwn(backup.sandbox, "permissions")
83
+ && Object.hasOwn(result.priorSandbox, "permissions")
84
+ ) {
85
+ backup.sandbox.permissions = result.priorSandbox.permissions;
81
86
  }
82
87
  console.log(`Claude Code -> GATEWAY (${result.path})`);
83
88
  console.log(` apiKeyHelper = "${result.apiKeyHelper}"`);
@@ -99,12 +104,19 @@ async function useGateway({ target, app }) {
99
104
  if (result.priorProviderValue !== PROVIDER_ID) {
100
105
  backup.model_provider = result.priorProviderValue ?? null;
101
106
  }
107
+ if (!Object.hasOwn(backup, "network_access")) {
108
+ backup.network_access = result.priorNetworkAccess;
109
+ }
110
+ if (!Object.hasOwn(backup, "sandbox_mode")) {
111
+ backup.sandbox_mode = result.priorSandboxMode;
112
+ }
102
113
 
103
114
  console.log(`Codex ${app ? "app/IDE" : "CLI"} -> GATEWAY (${result.path})`);
104
115
  console.log(` model_provider = "${PROVIDER_ID}"`);
105
116
  console.log(` [model_providers.${PROVIDER_ID}] base_url = "${result.baseUrl}"`);
106
117
  console.log(` [model_providers.${PROVIDER_ID}.auth] = direct Impel CLI invocation`);
107
118
  console.log(` [mcp_servers.${PROVIDER_ID}] = direct Impel CLI invocation`);
119
+ console.log(` sandbox workspace-write network = unrestricted`);
108
120
  }
109
121
 
110
122
  saveConfig(config);
@@ -194,6 +206,8 @@ function useAccount({ target, app }) {
194
206
  : ` removed model_provider line (Codex falls back to its default)`
195
207
  );
196
208
  }
209
+ if (result.restoredNetworkAccess) console.log(` restored prior Codex sandbox network setting`);
210
+ if (result.restoredSandboxMode) console.log(` restored prior Codex sandbox mode`);
197
211
  }
198
212
  if (config?.backups) delete config.backups.codex;
199
213
  }
@@ -6,6 +6,7 @@ import readline from "node:readline";
6
6
 
7
7
  import { nativeCommandInvocation } from "../nativeProcess.js";
8
8
  import { applyImpelClaudeSandbox } from "../claudeSandbox.js";
9
+ import { enableManagedCodexNetworkAccess } from "../codexSecurity.js";
9
10
 
10
11
  const SAFE_ID = /^[a-z][a-z0-9-]{2,31}$/u;
11
12
  const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
@@ -355,7 +356,7 @@ export function createGatewayCli(options) {
355
356
  const withoutProvider = outside.replace(/^\s*model_provider\s*=.*$/mu, "").trim();
356
357
  const next = [`model_provider = ${JSON.stringify(brand.cli.providerId)}`, codexManagedBlock(gatewayUrl), withoutProvider]
357
358
  .filter(Boolean).join("\n\n").concat("\n");
358
- writePrivateFile(filePath, next);
359
+ writePrivateFile(filePath, enableManagedCodexNetworkAccess(next, filePath));
359
360
  return { codexHome, filePath };
360
361
  }
361
362