impel-cli 0.16.1 → 0.16.3

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,19 +551,40 @@ 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:
555
+ the isolated `impel claude` profile, each tenant's Claude Desktop Code profile,
556
+ 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
+ Desktop profiles to third-party deployment mode, enables the 3P Chat tab so
559
+ Code's "Back to Claude" route has a valid destination, and removes the unusable
560
+ Claude.ai sign-in path. Existing tenant profiles refresh onto these policies
561
+ after the CLI update.
562
+
554
563
  ### `impel use gateway [claude|codex|all]` (alias `impel on`)
555
564
 
556
565
  Turns **on** gateway mode for the given tool (default `all`). Requires `impel auth`
557
566
  first.
558
567
 
559
568
  **Claude Code** — merges into `~/.claude/settings.json` (creating it if needed,
560
- leaving every other key — permissions, hooks, other env vars untouched):
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
571
+ default: Bash runs inside the OS sandbox without routine prompts, the
572
+ `dangerouslyDisableSandbox` escape hatch is disabled, and supported platforms
573
+ fail closed if isolation cannot initialize. Native Windows keeps startup
574
+ available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
561
575
 
562
576
  ```json
563
577
  {
564
578
  "apiKeyHelper": "impel token",
565
579
  "env": {
566
580
  "ANTHROPIC_BASE_URL": "https://gateway.useimpel.com/anthropic"
581
+ },
582
+ "sandbox": {
583
+ "enabled": true,
584
+ "failIfUnavailable": true,
585
+ "autoAllowBashIfSandboxed": true,
586
+ "allowUnsandboxedCommands": false,
587
+ "excludedCommands": []
567
588
  }
568
589
  }
569
590
  ```
@@ -597,9 +618,9 @@ refresh_interval_ms = 300000
597
618
  ```
598
619
 
599
620
  Before overwriting anything, `impel` **backs up** what was there (any prior
600
- `apiKeyHelper` / `ANTHROPIC_BASE_URL`, and the prior Codex `model_provider`
601
- value) into `~/.config/impel/config.json`, so `impel use account` can restore it
602
- exactly. If `config.toml` already has a `[model_providers.impel]` table that
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`
623
+ can restore it exactly. If `config.toml` already has a `[model_providers.impel]` table that
603
624
  wasn't written by this tool, `impel` refuses to touch the file and tells you to
604
625
  resolve it manually rather than risk emitting invalid/duplicate TOML.
605
626
 
@@ -908,7 +929,7 @@ impel status # see where each tool stands right now
908
929
  ```
909
930
 
910
931
  How the clean revert is guaranteed: `impel on` records the pre-existing values
911
- (your own `apiKeyHelper` / `ANTHROPIC_BASE_URL`, and Codex's prior
932
+ (your own `apiKeyHelper` / `ANTHROPIC_BASE_URL` / Claude sandbox policy, and Codex's prior
912
933
  `model_provider`) into `~/.config/impel/config.json` **before** overwriting them,
913
934
  and only ever captures a real *account-mode* value (re-running `impel on` never
914
935
  clobbers the backup with Impel's own values). `impel off` removes only keys that
@@ -916,8 +937,9 @@ still point at Impel and restores those backups — so a full `on` → `off`
916
937
  round-trip leaves `settings.json` and `config.toml` byte-for-byte back to where
917
938
  they started, with all your unrelated keys and tables intact.
918
939
 
919
- If you'd rather revert by hand: delete `apiKeyHelper` + the Impel
920
- `ANTHROPIC_BASE_URL` from `~/.claude/settings.json`; delete the block between the
940
+ If you'd rather revert by hand: delete `apiKeyHelper`, the Impel
941
+ `ANTHROPIC_BASE_URL`, and the Impel-managed sandbox keys from
942
+ `~/.claude/settings.json`; delete the block between the
921
943
  `# >>> impel-cli managed block ... >>>` / `# <<< impel-cli managed block <<<`
922
944
  markers in `~/.codex/config.toml` and reset `model_provider`; and
923
945
  `rm -rf ~/.config/impel` to forget the PAT + gateway entirely.
@@ -969,6 +991,12 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
969
991
  - https://developers.openai.com/codex/auth
970
992
  - https://developers.openai.com/codex/ide
971
993
 
994
+ ## Sources consulted for the Claude sandbox config
995
+
996
+ - https://code.claude.com/docs/en/settings#sandbox-settings
997
+ - 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
999
+
972
1000
  ## Sources consulted for concurrent tenant apps
973
1001
 
974
1002
  - Electron v43.1.0 release: https://github.com/electron/electron/releases/tag/v43.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
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
@@ -13,6 +13,7 @@ import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation } from "./selfInvocation.js";
14
14
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
15
15
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
16
+ import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
16
17
 
17
18
  export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
18
19
  const CHATGPT_CONFIG_START = "# >>> impel app managed gateway >>>";
@@ -162,7 +163,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
162
163
 
163
164
  // Bump when the written config/manifest schema changes; a mismatch forces the
164
165
  // slow open path (and thus a full config rewrite) after a CLI update.
165
- export const CURRENT_CONFIG_VERSION = 11;
166
+ export const CURRENT_CONFIG_VERSION = 14;
166
167
 
167
168
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
168
169
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -171,7 +172,7 @@ export const CURRENT_CONFIG_VERSION = 11;
171
172
  // — which is what made every `impel update` re-trigger macOS permission
172
173
  // prompts. Bump this ONLY when a code change alters the bytes of a built
173
174
  // bundle; leave it alone for changes that don't touch bundle contents.
174
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-17.1";
175
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-19.1";
175
176
 
176
177
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
177
178
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -206,8 +207,24 @@ export function readBundleCompatibility(launcher) {
206
207
  * closed, so a routine CLI update that doesn't change bundle bytes must leave
207
208
  * the installed bundles — and their macOS permission grants — untouched.
208
209
  */
209
- export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } = {}) {
210
+ export function bundleIsCurrent(status, {
211
+ signingMode = desiredSigningMode(),
212
+ platform = process.platform,
213
+ osRelease = os.release(),
214
+ } = {}) {
210
215
  if (!status.launcherInstalled || !status.vendorPath || !status.vendorVersion) return false;
216
+ // macOS 26 (Darwin 25) refuses to launch bundles whose CFBundleExecutable is
217
+ // a script (-10669). A wrapper in that state — either target — is broken
218
+ // regardless of its fingerprint; reporting it stale makes the next
219
+ // install/open rebuild it into a launchable layout (e.g. the ChatGPT
220
+ // launch-host layout once a compiler exists), with no fingerprint bump.
221
+ if (
222
+ platform === "darwin"
223
+ && Number.parseInt(osRelease, 10) >= 25
224
+ && !wrapperLaunchesOnTahoe(status)
225
+ ) {
226
+ return false;
227
+ }
211
228
  const compatibility = readBundleCompatibility(status.launcher);
212
229
  return Boolean(
213
230
  compatibility
@@ -237,6 +254,28 @@ export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } =
237
254
  );
238
255
  }
239
256
 
257
+ /**
258
+ * Whether a wrapper bundle's main executable is one macOS 26 will launch.
259
+ * The ChatGPT wrapper must be on the launch-host layout: its script fallback
260
+ * is exactly what Tahoe refuses (-10669). The Claude wrapper retains the
261
+ * vendor's Mach-O executable, so its only unlaunchable state is that
262
+ * executable going missing — the Mach-O bytes themselves are not probed
263
+ * because test fixtures legitimately stand in scripts for the vendor binary.
264
+ */
265
+ function wrapperLaunchesOnTahoe(status) {
266
+ try {
267
+ const plist = fs.readFileSync(path.join(status.launcher, "Contents", "Info.plist"), "utf8");
268
+ const executable = plist.match(/<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/u)?.[1];
269
+ if (!executable) return false;
270
+ const file = path.join(status.launcher, "Contents", "MacOS", executable);
271
+ if (!fs.existsSync(file)) return false;
272
+ if (status.target === "chatgpt") return executable === "launch-host" && isMachO(file);
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
240
279
  function managedClaudeRendererMatchesCompatibility(status, compatibility) {
241
280
  try {
242
281
  const renderer = compatibility.rendererAssets?.agentMentions;
@@ -576,6 +615,7 @@ export function installManagedAppFiles({
576
615
  if (target === "claude") {
577
616
  migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
578
617
  writeClaudeDefaultApp(paths);
618
+ writeClaudeCodeSettings(paths);
579
619
  writeClaudeConfig(paths, config, models);
580
620
  }
581
621
  else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
@@ -851,6 +891,17 @@ function writeClaudeConfig(paths, config, models) {
851
891
  inferenceGatewayAuthScheme: "bearer",
852
892
  modelDiscoveryEnabled: false,
853
893
  inferenceModels: claudeModels,
894
+ // 3P builds leave Chat unavailable unless the administrator opts in. The
895
+ // Code install/disabled screen's "Back to Claude" link targets the Chat
896
+ // root; without this key the root router selects Code again and strands
897
+ // gateway users in a loop even though inference is healthy.
898
+ chatTabEnabled: true,
899
+ // Managed flat key (maps to authentication.disableClaudeAiSignIn, vendor
900
+ // 1.3834.0+): hides the Claude.ai sign-in option and keeps the app in 3P
901
+ // mode even when the persisted deploymentMode is missing or "1p". Claude.ai
902
+ // account login is a dead end for gateway profiles anyway — the server
903
+ // rejects it with custom_3p_not_available.
904
+ disableDeploymentModeChooser: true,
854
905
  };
855
906
  writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
856
907
  writeAtomic(path.join(dir, "_meta.json"), JSON.stringify({
@@ -859,6 +910,23 @@ function writeClaudeConfig(paths, config, models) {
859
910
  }, null, 2) + "\n", 0o600);
860
911
  }
861
912
 
913
+ function writeClaudeCodeSettings(paths) {
914
+ const settingsPath = path.join(paths.claude.userData, "settings.json");
915
+ let settings = {};
916
+ if (fs.existsSync(settingsPath)) {
917
+ try {
918
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
919
+ } catch {
920
+ throw new Error(`Claude Code settings are not valid JSON: ${settingsPath}`);
921
+ }
922
+ }
923
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
924
+ throw new Error(`Claude Code settings must contain a JSON object: ${settingsPath}`);
925
+ }
926
+ applyImpelClaudeSandbox(settings);
927
+ writeAtomic(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
928
+ }
929
+
862
930
  function writeClaudeDefaultApp(paths) {
863
931
  const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
864
932
  let current = {};
@@ -876,14 +944,29 @@ function writeClaudeDefaultApp(paths) {
876
944
  if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
877
945
  throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
878
946
  }
947
+ // The desktop app gates its UI on a persisted top-level `deploymentMode`:
948
+ // until it is "3p", every launch lands on the sign-in / provider-chooser
949
+ // screen — even though the applied configLibrary gateway config loads fine —
950
+ // and Claude.ai email login is server-disabled for 3P profiles
951
+ // (custom_3p_not_available). A managed gateway profile is only ever 3P, so
952
+ // enforce the key like the sandbox policy (heal "1p"/cleared values too,
953
+ // e.g. after an in-app sign-out), not seed-once like a user preference.
954
+ const needsDeploymentMode = current.deploymentMode !== "3p";
879
955
  // Claude persists the selected top-level app as `preferences.sidebarMode`.
880
956
  // Seed Code for a new isolated profile, then leave subsequent user choices
881
957
  // alone during install, update, and background config refreshes.
882
- if (Object.hasOwn(preferences, "sidebarMode")) return;
883
- writeAtomic(configPath, JSON.stringify({
958
+ const needsSidebarMode = !Object.hasOwn(preferences, "sidebarMode");
959
+ if (!needsDeploymentMode && !needsSidebarMode) return;
960
+ const next = {
884
961
  ...current,
885
- preferences: { ...preferences, sidebarMode: "code" },
886
- }, null, 2) + "\n", 0o600);
962
+ ...(needsDeploymentMode ? { deploymentMode: "3p" } : {}),
963
+ preferences: needsSidebarMode ? { ...preferences, sidebarMode: "code" } : preferences,
964
+ };
965
+ // A vendor sign-out writes `awaitingSignIn: true` alongside "1p". Left in
966
+ // place it re-arms the app's quit-time revert — which deletes deploymentMode
967
+ // again — should the gateway config ever fail to load. Drop it when healing.
968
+ if (needsDeploymentMode) delete next.awaitingSignIn;
969
+ writeAtomic(configPath, JSON.stringify(next, null, 2) + "\n", 0o600);
887
970
  }
888
971
 
889
972
  function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
@@ -1367,16 +1450,28 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1367
1450
  const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
1368
1451
  const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
1369
1452
  const asarHash = asarHeaderHash(runtimeAsarPath);
1453
+ const launchHost = ensureChatGPTLaunchHost();
1370
1454
  writeAtomic(
1371
1455
  path.join(staging, "Contents", "Info.plist"),
1372
- vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
1456
+ vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt, launchHost ? "launch-host" : "launch"),
1373
1457
  0o644,
1374
1458
  );
1459
+ // With the Mach-O launch host as the bundle executable, the launch script
1460
+ // lives in Resources (codesign seals it as a resource; anything else in
1461
+ // Contents/MacOS would need its own code signature). Its $HERE/../Resources
1462
+ // references resolve identically from either directory. Without a compiler
1463
+ // the script stays in MacOS as the executable (pre-Tahoe layout).
1375
1464
  writeAtomic(
1376
- path.join(macos, "launch"),
1465
+ path.join(launchHost ? path.join(staging, "Contents", "Resources") : macos, "launch"),
1377
1466
  vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
1378
1467
  0o755,
1379
1468
  );
1469
+ if (launchHost) {
1470
+ fs.mkdirSync(macos, { recursive: true });
1471
+ const hostTarget = path.join(macos, "launch-host");
1472
+ fs.copyFileSync(launchHost, hostTarget);
1473
+ fs.chmodSync(hostTarget, 0o755);
1474
+ }
1380
1475
  writeAtomic(
1381
1476
  path.join(staging, "Contents", "Resources", "impel-chatgpt-preload.cjs"),
1382
1477
  vendoredChatGPTDisplayNamePreload(),
@@ -1760,12 +1855,101 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
1760
1855
  }
1761
1856
  }
1762
1857
 
1763
- function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity) {
1858
+ // macOS 26 (Tahoe) Launch Services refuses to launch a bundle whose
1859
+ // CFBundleExecutable is a script (_LSOpenURLsWithCompletionHandler error
1860
+ // -10669), so the wrapper's main executable must be a real Mach-O. This tiny
1861
+ // host binary exec()s the launch script in Contents/Resources, preserving the
1862
+ // script-based setup flow and the bundle's designated requirement.
1863
+ const CHATGPT_LAUNCH_HOST_SOURCE = `#include <libgen.h>
1864
+ #include <limits.h>
1865
+ #include <mach-o/dyld.h>
1866
+ #include <stdint.h>
1867
+ #include <stdio.h>
1868
+ #include <stdlib.h>
1869
+ #include <string.h>
1870
+ #include <unistd.h>
1871
+
1872
+ int main(int argc, char *argv[]) {
1873
+ char exe[PATH_MAX];
1874
+ uint32_t size = sizeof(exe);
1875
+ if (_NSGetExecutablePath(exe, &size) != 0) return 1;
1876
+ char resolved[PATH_MAX];
1877
+ if (!realpath(exe, resolved)) return 1;
1878
+ char script[PATH_MAX];
1879
+ if (snprintf(script, sizeof(script), "%s/../Resources/launch", dirname(resolved)) >= (int)sizeof(script)) return 1;
1880
+ char **args = calloc((size_t)argc + 1, sizeof(char *));
1881
+ if (!args) return 1;
1882
+ args[0] = script;
1883
+ for (int i = 1; i < argc; i++) args[i] = argv[i];
1884
+ args[argc] = NULL;
1885
+ execv(script, args);
1886
+ perror("execv");
1887
+ return 1;
1888
+ }
1889
+ `;
1890
+
1891
+ /**
1892
+ * Compile (once) and cache the Mach-O launch host for the ChatGPT wrapper.
1893
+ * Returns the cached binary's path, or null when it cannot be built (no
1894
+ * compiler); callers then fall back to the script-only layout. That fallback
1895
+ * still launches on macOS ≤ 15, but macOS 26 refuses it (-10669), so the
1896
+ * failure is reported loudly there instead of silently installing an app
1897
+ * that will never open.
1898
+ */
1899
+ export function ensureChatGPTLaunchHost({
1900
+ platform = process.platform,
1901
+ osRelease = os.release(),
1902
+ compiler = "/usr/bin/cc",
1903
+ cacheDir = fileURLToPath(new URL("../cache", import.meta.url)),
1904
+ warn = console.warn,
1905
+ } = {}) {
1906
+ if (platform !== "darwin") return null;
1907
+ const sourceHash = crypto.createHash("sha256").update(CHATGPT_LAUNCH_HOST_SOURCE).digest("hex").slice(0, 12);
1908
+ const hostPath = path.join(cacheDir, `launch-host-${sourceHash}`);
1909
+ const failed = (detail) => {
1910
+ // Darwin 25 == macOS 26, the first release that refuses script-executable
1911
+ // bundles; earlier releases launch the fallback layout fine, so stay quiet.
1912
+ if (Number.parseInt(osRelease, 10) >= 25) {
1913
+ warn(
1914
+ `impel: could not compile the Impel ChatGPT launch helper (${redactSecretText(String(detail || "unknown error"))}); `
1915
+ + "macOS 26 refuses to open the script-based fallback app. Install the Xcode Command Line Tools "
1916
+ + "(xcode-select --install), then run `impel app update chatgpt`.",
1917
+ );
1918
+ }
1919
+ return null;
1920
+ };
1921
+ const building = path.join(cacheDir, `.launch-host-${sourceHash}.tmp-${process.pid}`);
1922
+ try {
1923
+ if (fs.existsSync(hostPath) && isMachO(hostPath)) return hostPath;
1924
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
1925
+ const source = path.join(os.tmpdir(), `impel-launch-host-${process.pid}.c`);
1926
+ fs.writeFileSync(source, CHATGPT_LAUNCH_HOST_SOURCE, { mode: 0o600 });
1927
+ // Compile to a process-private path and rename into place: concurrent
1928
+ // installs share this cache, and a reader must never see a partial binary.
1929
+ const built = spawnSync(compiler, ["-O2", "-o", building, source], { encoding: "utf8" });
1930
+ fs.rmSync(source, { force: true });
1931
+ if (built.error || built.status !== 0 || !fs.existsSync(building) || !isMachO(building)) {
1932
+ fs.rmSync(building, { force: true });
1933
+ const detail = built.error?.message
1934
+ || String(built.stderr || "").trim().split("\n")[0]
1935
+ || `compiler exit code ${built.status}`;
1936
+ return failed(detail);
1937
+ }
1938
+ fs.chmodSync(building, 0o755);
1939
+ fs.renameSync(building, hostPath);
1940
+ return hostPath;
1941
+ } catch (error) {
1942
+ fs.rmSync(building, { force: true });
1943
+ return failed(error?.message);
1944
+ }
1945
+ }
1946
+
1947
+ function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity, executableName = "launch") {
1764
1948
  return `<?xml version="1.0" encoding="UTF-8"?>
1765
1949
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1766
1950
  <plist version="1.0"><dict>
1767
1951
  <key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
1768
- <key>CFBundleExecutable</key><string>launch</string>
1952
+ <key>CFBundleExecutable</key><string>${xmlEscape(executableName)}</string>
1769
1953
  <key>CFBundleIconFile</key><string>electron.icns</string>
1770
1954
  <key>CFBundleIdentifier</key><string>${xmlEscape(identity.bundleIdentifier)}</string>
1771
1955
  <key>CFBundleName</key><string>${xmlEscape(identity.displayName)}</string>
@@ -0,0 +1,80 @@
1
+ const MANAGED_SANDBOX_KEYS = Object.freeze([
2
+ "enabled",
3
+ "failIfUnavailable",
4
+ "autoAllowBashIfSandboxed",
5
+ "allowUnsandboxedCommands",
6
+ "excludedCommands",
7
+ ]);
8
+
9
+ function isJsonObject(value) {
10
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
11
+ }
12
+
13
+ function sameJsonValue(left, right) {
14
+ return JSON.stringify(left) === JSON.stringify(right);
15
+ }
16
+
17
+ /**
18
+ * Claude Code's strict, auto-allow sandbox profile.
19
+ *
20
+ * Native Windows does not implement Claude's sandbox. Keep it enabled so the
21
+ * same profile becomes effective in WSL, but do not make native Windows fail
22
+ * at startup solely because the platform cannot initialize it.
23
+ */
24
+ export function impelClaudeSandboxPolicy(platform = process.platform) {
25
+ return {
26
+ enabled: true,
27
+ failIfUnavailable: platform !== "win32",
28
+ autoAllowBashIfSandboxed: true,
29
+ allowUnsandboxedCommands: false,
30
+ excludedCommands: [],
31
+ };
32
+ }
33
+
34
+ /** Apply the managed sandbox keys while preserving unrelated sandbox config. */
35
+ export function applyImpelClaudeSandbox(settings, platform = process.platform) {
36
+ const current = isJsonObject(settings.sandbox) ? settings.sandbox : {};
37
+ settings.sandbox = {
38
+ ...current,
39
+ ...impelClaudeSandboxPolicy(platform),
40
+ };
41
+ return settings.sandbox;
42
+ }
43
+
44
+ /** Capture the exact pre-Impel value for reversible native-profile switching. */
45
+ export function captureClaudeSandbox(settings) {
46
+ return Object.hasOwn(settings, "sandbox")
47
+ ? { present: true, value: settings.sandbox }
48
+ : { present: false };
49
+ }
50
+
51
+ /**
52
+ * Restore only managed keys that still hold Impel's values. User changes made
53
+ * while gateway mode was active, plus unrelated filesystem/network keys, win.
54
+ */
55
+ export function restoreClaudeSandbox(settings, snapshot, platform = process.platform) {
56
+ if (!isJsonObject(settings.sandbox)) return false;
57
+ const policy = impelClaudeSandboxPolicy(platform);
58
+ const prior = snapshot?.present === true && isJsonObject(snapshot.value)
59
+ ? snapshot.value
60
+ : null;
61
+ const sandbox = { ...settings.sandbox };
62
+ let changed = false;
63
+
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
+ }
70
+
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;
78
+ }
79
+ return true;
80
+ }
@@ -10,6 +10,11 @@ import os from "node:os";
10
10
  import path from "node:path";
11
11
 
12
12
  import { IMPEL_MANAGED_MCP_ENV, impelMcpInvocation } from "./selfInvocation.js";
13
+ import {
14
+ applyImpelClaudeSandbox,
15
+ captureClaudeSandbox,
16
+ restoreClaudeSandbox,
17
+ } from "./claudeSandbox.js";
13
18
 
14
19
  export const CLAUDE_DIR = path.join(os.homedir(), ".claude");
15
20
  export const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
@@ -123,12 +128,14 @@ export function applyClaudeGateway(gatewayUrl) {
123
128
 
124
129
  const priorApiKeyHelper = settings.apiKeyHelper;
125
130
  const priorBaseUrl = settings.env?.ANTHROPIC_BASE_URL;
131
+ const priorSandbox = captureClaudeSandbox(settings);
126
132
 
127
133
  settings.apiKeyHelper = IMPEL_API_KEY_HELPER;
128
134
  settings.env = {
129
135
  ...(settings.env || {}),
130
136
  ANTHROPIC_BASE_URL: impelClaudeBaseUrl(gatewayUrl),
131
137
  };
138
+ const sandbox = applyImpelClaudeSandbox(settings);
132
139
 
133
140
  writeSettings(settings);
134
141
  userConfig.mcpServers = {
@@ -143,6 +150,8 @@ export function applyClaudeGateway(gatewayUrl) {
143
150
  baseUrl: settings.env.ANTHROPIC_BASE_URL,
144
151
  priorApiKeyHelper,
145
152
  priorBaseUrl,
153
+ priorSandbox,
154
+ sandbox,
146
155
  };
147
156
  }
148
157
 
@@ -179,6 +188,9 @@ export function revertClaudeGateway(gatewayUrl, backup = {}) {
179
188
  changed = true;
180
189
  }
181
190
 
191
+ const restoredSandbox = restoreClaudeSandbox(settings, backup.sandbox);
192
+ if (restoredSandbox) changed = true;
193
+
182
194
  if (changed) writeSettings(settings);
183
195
 
184
196
  if (isImpelMcpServer(userConfig.mcpServers?.[IMPEL_MCP_SERVER_NAME])) {
@@ -201,6 +213,7 @@ export function revertClaudeGateway(gatewayUrl, backup = {}) {
201
213
  removedHelper,
202
214
  removedBaseUrl,
203
215
  removedMcpServer,
216
+ restoredSandbox,
204
217
  restoredApiKeyHelper: removedHelper && backup.apiKeyHelper != null ? backup.apiKeyHelper : null,
205
218
  restoredBaseUrl: removedBaseUrl && backup.ANTHROPIC_BASE_URL != null ? backup.ANTHROPIC_BASE_URL : null,
206
219
  };
@@ -11,6 +11,7 @@ import {
11
11
  import { CONFIG_DIR } from "./config.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
14
+ import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
14
15
 
15
16
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
16
17
 
@@ -118,6 +119,7 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
118
119
  delete managedEnvironment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY;
119
120
  }
120
121
  settings.env = managedEnvironment;
122
+ applyImpelClaudeSandbox(settings);
121
123
  userConfig.mcpServers = {
122
124
  ...(userConfig.mcpServers &&
123
125
  typeof userConfig.mcpServers === "object" &&
@@ -986,13 +986,14 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
986
986
  }
987
987
  }
988
988
 
989
- function manifestIsFresh(paths, config, now = Date.now()) {
989
+ export function manifestIsFresh(paths, config, now = Date.now()) {
990
990
  try {
991
991
  const manifest = JSON.parse(
992
992
  fs.readFileSync(path.join(paths.tenantRoot, "manifest.json"), "utf8")
993
993
  );
994
994
  const updatedAt = Date.parse(manifest?.updatedAt || "");
995
995
  return Number.isFinite(updatedAt)
996
+ && manifest?.configVersion === CURRENT_CONFIG_VERSION
996
997
  && manifest?.experiments?.crossAppModels === crossAppModelsEnabled(config)
997
998
  && now - updatedAt < REFRESH_TTL_MS;
998
999
  } catch {
@@ -76,9 +76,13 @@ async function useGateway({ target, app }) {
76
76
  if (!isImpelClaudeBaseUrl(result.priorBaseUrl, gatewayUrl)) {
77
77
  backup.ANTHROPIC_BASE_URL = result.priorBaseUrl ?? null;
78
78
  }
79
+ if (!Object.hasOwn(backup, "sandbox")) {
80
+ backup.sandbox = result.priorSandbox;
81
+ }
79
82
  console.log(`Claude Code -> GATEWAY (${result.path})`);
80
83
  console.log(` apiKeyHelper = "${result.apiKeyHelper}"`);
81
84
  console.log(` env.ANTHROPIC_BASE_URL = "${result.baseUrl}"`);
85
+ console.log(` sandbox = strict auto-allow (${result.sandbox.failIfUnavailable ? "fail closed" : "platform fallback"})`);
82
86
  console.log(` MCP server = "impel" (direct Impel CLI invocation)`);
83
87
  }
84
88
 
@@ -167,6 +171,9 @@ function useAccount({ target, app }) {
167
171
  if (result.removedMcpServer) {
168
172
  console.log(` removed Impel MCP server`);
169
173
  }
174
+ if (result.restoredSandbox) {
175
+ console.log(` restored prior Claude sandbox settings`);
176
+ }
170
177
  }
171
178
  if (config?.backups) delete config.backups.claude;
172
179
  }
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import readline from "node:readline";
6
6
 
7
7
  import { nativeCommandInvocation } from "../nativeProcess.js";
8
+ import { applyImpelClaudeSandbox } from "../claudeSandbox.js";
8
9
 
9
10
  const SAFE_ID = /^[a-z][a-z0-9-]{2,31}$/u;
10
11
  const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
@@ -301,6 +302,7 @@ export function createGatewayCli(options) {
301
302
  };
302
303
  delete settings.env.ANTHROPIC_API_KEY;
303
304
  delete settings.env.ANTHROPIC_AUTH_TOKEN;
305
+ applyImpelClaudeSandbox(settings);
304
306
  writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
305
307
  return { configDir, settingsPath };
306
308
  }