impel-cli 0.16.3 → 0.16.5

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
@@ -25,6 +25,32 @@ of the protected Store directory and launches it with tenant-specific Codex and
25
25
  browser profiles. Each approach keeps the user's normal app profile and
26
26
  signed-in account untouched.
27
27
 
28
+ Every Impel-managed Claude Code, Codex CLI, Claude Desktop, and ChatGPT/Codex
29
+ Desktop profile also installs tenant-scoped lifecycle hooks for cloud session
30
+ persistence. Hook input is first written to a private local outbox, so an
31
+ offline network, interrupted hook, force-quit, or client restart cannot make a
32
+ provider turn fail. The next hook invocation retries any pending batches,
33
+ including batches left by an older session. Remote payloads are stored as
34
+ independent zstd frames by `impel-sessions`; the local collector sends bounded
35
+ newline-complete transcript deltas and a compact lifecycle ledger. Pending data
36
+ is capped at 256 MiB or 2,048 batches per session by default; corrupt or
37
+ permanently invalid batches are quarantined so they cannot block later data.
38
+ Set `IMPEL_SESSIONS_MAX_OUTBOX_BYTES` or `IMPEL_SESSIONS_MAX_OUTBOX_BATCHES` to
39
+ lower those local limits.
40
+
41
+ The service attributes each session to the PAT-resolved user and selected
42
+ tenant and records the provider, CLI/desktop surface, provider session ID, and
43
+ repository metadata. Set `IMPEL_TASK_ID` before launching a client to link new
44
+ sessions explicitly to an Impel task for cross-device discovery. The collector
45
+ never writes the configured PAT into hook configuration, and it redacts Impel
46
+ PAT/tenant credentials found in hook input or transcript content before
47
+ spooling. Task linkage is retried independently and cannot block payload
48
+ persistence when the current PAT lacks the `tasks` scope.
49
+
50
+ Codex hook trust is pinned to the exact generated handler and its array index.
51
+ If you manually reorder the managed group in `hooks.json`, rerun `impel setup`,
52
+ `impel app update`, or relaunch the isolated CLI to regenerate the trust entry.
53
+
28
54
  ## Gateway-only vendor package
29
55
 
30
56
  White-labelled gateway launchers import the deliberately narrow
@@ -551,10 +577,11 @@ impel codex exec --json "review the current diff"
551
577
  switch their native profiles in place; the isolated launchers are the safer
552
578
  default when personal and Impel sessions should coexist.
553
579
 
554
- Every Impel-managed Claude configuration uses the same strict sandbox defaults:
580
+ Every Impel-managed Claude configuration uses the same strict sandbox defaults
581
+ with unrestricted outbound network access:
555
582
  the isolated `impel claude` profile, each tenant's Claude Desktop Code profile,
556
583
  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
584
+ gateway-only CLI profiles. App config version 15 also pins managed Claude
558
585
  Desktop profiles to third-party deployment mode, enables the 3P Chat tab so
559
586
  Code's "Back to Claude" route has a valid destination, and removes the unusable
560
587
  Claude.ai sign-in path. Existing tenant profiles refresh onto these policies
@@ -566,12 +593,16 @@ Turns **on** gateway mode for the given tool (default `all`). Requires `impel au
566
593
  first.
567
594
 
568
595
  **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
596
+ leaving existing permission rules, hooks, other env vars, and custom sandbox
597
+ filesystem/network rules intact). Gateway mode enables Claude's strict auto-allow sandbox by
571
598
  default: Bash runs inside the OS sandbox without routine prompts, the
572
599
  `dangerouslyDisableSandbox` escape hatch is disabled, and supported platforms
573
600
  fail closed if isolation cannot initialize. Native Windows keeps startup
574
601
  available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
602
+ A global `WebFetch(domain:*)` allow rule opens every outbound destination for Bash commands
603
+ while retaining filesystem isolation. Explicit deny rules remain authoritative.
604
+ On macOS, `enableWeakerNetworkIsolation` lets Go-based network tools reach the
605
+ system TLS trust service while remaining sandboxed.
575
606
 
576
607
  ```json
577
608
  {
@@ -579,12 +610,16 @@ available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
579
610
  "env": {
580
611
  "ANTHROPIC_BASE_URL": "https://gateway.useimpel.com/anthropic"
581
612
  },
613
+ "permissions": {
614
+ "allow": ["WebFetch(domain:*)"]
615
+ },
582
616
  "sandbox": {
583
617
  "enabled": true,
584
618
  "failIfUnavailable": true,
585
619
  "autoAllowBashIfSandboxed": true,
586
620
  "allowUnsandboxedCommands": false,
587
- "excludedCommands": []
621
+ "excludedCommands": [],
622
+ "enableWeakerNetworkIsolation": true
588
623
  }
589
624
  }
590
625
  ```
@@ -596,7 +631,9 @@ available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
596
631
  as Claude Code's `apiKeyHelper`), so we reuse `impel token` there too instead of
597
632
  writing the PAT into `config.toml` or `~/.codex/auth.json`. The block written
598
633
  (fenced by markers, so everything else in your `config.toml` — other
599
- `model_providers`, `mcp_servers`, sandbox policy — is left alone):
634
+ `model_providers`, `mcp_servers`, and unrelated sandbox policy — is left alone).
635
+ Codex defaults to the `workspace-write` filesystem sandbox with unrestricted
636
+ direct outbound access; its optional network proxy stays disabled:
600
637
 
601
638
  ```toml
602
639
  model_provider = "impel"
@@ -604,6 +641,9 @@ model_provider = "impel"
604
641
  # >>> impel-cli managed block (model_providers.impel) >>>
605
642
  # Generated by `impel use gateway codex`. Safe to re-run; do not hand-edit
606
643
  # the lines between the markers above/below, they'll be overwritten.
644
+ sandbox_mode = "workspace-write"
645
+ sandbox_workspace_write.network_access = true
646
+
607
647
  [model_providers.impel]
608
648
  name = "Impel Gateway"
609
649
  base_url = "https://gateway.useimpel.com/chatgpt_passthrough/backend-api/codex"
@@ -618,8 +658,9 @@ refresh_interval_ms = 300000
618
658
  ```
619
659
 
620
660
  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`
661
+ `apiKeyHelper`, `ANTHROPIC_BASE_URL`, Claude sandbox/permission policy, and the
662
+ prior Codex `model_provider`, sandbox mode, and workspace network values) into
663
+ `~/.config/impel/config.json`, so `impel use account`
623
664
  can restore it exactly. If `config.toml` already has a `[model_providers.impel]` table that
624
665
  wasn't written by this tool, `impel` refuses to touch the file and tells you to
625
666
  resolve it manually rather than risk emitting invalid/duplicate TOML.
@@ -632,11 +673,14 @@ removes **only** the Impel-added bits, never your own config:
632
673
  - **Claude Code** — removes `apiKeyHelper` only if it's `"impel token"`, and
633
674
  `env.ANTHROPIC_BASE_URL` only if it equals `<gateway>/anthropic`. Then restores
634
675
  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).
676
+ nothing before, they're deleted (and an emptied `env` is dropped). It also
677
+ removes only the Impel-added global `WebFetch(domain:*)` allow rule.
636
678
  - **Codex** — removes the marker-fenced `[model_providers.impel]` block, and
637
679
  resets `model_provider` **only if it currently reads `"impel"`** — back to its
638
680
  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.
681
+ Codex falls back to its built-in default. The prior
682
+ `sandbox_mode` and `sandbox_workspace_write.network_access` values are
683
+ restored. All other tables are untouched.
640
684
 
641
685
  `impel use account` doesn't require the PAT and is safe to run repeatedly (if a
642
686
  tool is already in account mode it just says so and changes nothing).
@@ -929,8 +973,10 @@ impel status # see where each tool stands right now
929
973
  ```
930
974
 
931
975
  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,
976
+ (your own `apiKeyHelper` / `ANTHROPIC_BASE_URL` / Claude sandbox and permission
977
+ policy, and Codex's prior `model_provider`, sandbox mode, and workspace network
978
+ value) into
979
+ `~/.config/impel/config.json` **before** overwriting them,
934
980
  and only ever captures a real *account-mode* value (re-running `impel on` never
935
981
  clobbers the backup with Impel's own values). `impel off` removes only keys that
936
982
  still point at Impel and restores those backups — so a full `on` → `off`
@@ -939,9 +985,10 @@ they started, with all your unrelated keys and tables intact.
939
985
 
940
986
  If you'd rather revert by hand: delete `apiKeyHelper`, the Impel
941
987
  `ANTHROPIC_BASE_URL`, and the Impel-managed sandbox keys from
942
- `~/.claude/settings.json`; delete the block between the
988
+ `~/.claude/settings.json`, plus the Impel-added `WebFetch(domain:*)` allow rule; delete the block between the
943
989
  `# >>> impel-cli managed block ... >>>` / `# <<< impel-cli managed block <<<`
944
- markers in `~/.codex/config.toml` and reset `model_provider`; and
990
+ markers in `~/.codex/config.toml`, reset `model_provider`, and restore or remove
991
+ `sandbox_mode` and `sandbox_workspace_write.network_access`; and
945
992
  `rm -rf ~/.config/impel` to forget the PAT + gateway entirely.
946
993
 
947
994
  ## Codex compatibility route
@@ -980,9 +1027,9 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
980
1027
  `impel token` rather than copying it into their own config files — one token,
981
1028
  one rotation path, no stale copies.
982
1029
  - **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.
1030
+ parser: it touches the top-level `model_provider`, sandbox mode, the
1031
+ workspace-write network toggle, and its own fenced `[model_providers.impel*]` block, leaving unrelated
1032
+ tables (other providers, MCP servers, sandbox policy, etc.) untouched.
986
1033
 
987
1034
  ## Sources consulted for the Codex config
988
1035
 
@@ -995,7 +1042,7 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
995
1042
 
996
1043
  - https://code.claude.com/docs/en/settings#sandbox-settings
997
1044
  - 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
1045
+ - https://github.com/anthropics/claude-code/blob/v2.1.214/examples/settings/settings-bash-sandbox.json
999
1046
 
1000
1047
  ## Sources consulted for concurrent tenant apps
1001
1048
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
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
@@ -12,6 +12,7 @@ import {
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation } from "./selfInvocation.js";
14
14
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
15
+ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
15
16
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
16
17
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
17
18
 
@@ -163,7 +164,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
163
164
 
164
165
  // Bump when the written config/manifest schema changes; a mismatch forces the
165
166
  // slow open path (and thus a full config rewrite) after a CLI update.
166
- export const CURRENT_CONFIG_VERSION = 14;
167
+ export const CURRENT_CONFIG_VERSION = 16;
167
168
 
168
169
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
169
170
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -617,8 +618,12 @@ export function installManagedAppFiles({
617
618
  writeClaudeDefaultApp(paths);
618
619
  writeClaudeCodeSettings(paths);
619
620
  writeClaudeConfig(paths, config, models);
621
+ ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
622
+ }
623
+ else {
624
+ writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
625
+ ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
620
626
  }
621
- else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
622
627
  // Non-bundle targets are the background-refresh / fast-open path: configs,
623
628
  // token helper, catalog, and manifest only. Bundle swaps require the app
624
629
  // to be closed (see quitBlockingApps), so those paths never attempt one.
@@ -1024,6 +1029,7 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
1024
1029
  const managedToml = [
1025
1030
  CHATGPT_CONFIG_START,
1026
1031
  "# Managed by `impel app update`. This profile is isolated from ~/.codex.",
1032
+ "# Impel session-hook trust is pinned separately by exact handler hash.",
1027
1033
  `model = ${tomlString(selectedModel.slug)}`,
1028
1034
  'model_provider = "impel"',
1029
1035
  `chatgpt_base_url = ${tomlString(`${config.gatewayUrl}/chatgpt_passthrough/backend-api`)}`,
@@ -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
  }
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import { cmdAgents } from "./commands/agents.js";
15
15
  import { cmdTenant } from "./commands/tenant.js";
16
16
  import { cmdDoctor } from "./commands/doctor.js";
17
17
  import { cmdSetup } from "./commands/setup.js";
18
+ import { cmdSessions } from "./commands/sessions.js";
18
19
  import { cmdUpdate } from "./commands/update.js";
19
20
  import { cmdExperimental } from "./commands/experimental.js";
20
21
 
@@ -121,6 +122,10 @@ export async function main(argv) {
121
122
  case "mcp":
122
123
  return cmdMcp(rest);
123
124
 
125
+ // Hidden transport used by managed Claude/Codex lifecycle hooks.
126
+ case "sessions":
127
+ return cmdSessions(rest);
128
+
124
129
  case "claude":
125
130
  case "codex":
126
131
  return cmdLaunch(cmd, rest);
@@ -12,6 +12,7 @@ import { CONFIG_DIR } from "./config.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
14
14
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
15
+ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
15
16
 
16
17
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
17
18
 
@@ -131,6 +132,7 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
131
132
 
132
133
  writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
133
134
  writePrivateFile(userConfigPath, `${JSON.stringify(userConfig, null, 2)}\n`);
135
+ ensureClaudeSessionHooks(configDir, tenantId, "claude_cli");
134
136
 
135
137
  return { configDir, settingsPath, userConfigPath };
136
138
  }
@@ -203,9 +205,10 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
203
205
 
204
206
  const { preamble, rest } = splitTomlPreamble(withoutManagedBlock);
205
207
  const providerLine = 'model_provider = "impel"';
206
- const nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
208
+ let nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
207
209
  ? preamble.replace(CODEX_PROVIDER_LINE_RE, providerLine)
208
210
  : `${preamble.trimEnd()}${preamble.trim() ? "\n" : ""}${providerLine}\n`;
211
+ nextPreamble = nextPreamble.replace(/^bypass_hook_trust[ \t]*=[ \t]*(?:true|false)[ \t]*\n?/gm, "");
209
212
  const restText = rest.trim();
210
213
  const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
211
214
  .filter(Boolean)
@@ -213,6 +216,7 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
213
216
  .concat("\n");
214
217
 
215
218
  writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
219
+ ensureCodexSessionHooks(codexHome, tenantId, "codex_cli");
216
220
  secureManagedCodexHome(codexHome);
217
221
  return { codexHome, configPath };
218
222
  }
@@ -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) {