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 +39 -18
- package/package.json +1 -1
- package/src/apps.js +1 -1
- package/src/claudeSandbox.js +81 -18
- package/src/codexSecurity.js +43 -1
- package/src/codexSetup.js +146 -3
- package/src/commands/use.js +14 -0
- package/src/gateway/index.js +2 -1
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
|
|
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
|
|
570
|
-
rules
|
|
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
|
|
622
|
-
`model_provider
|
|
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.
|
|
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
|
|
933
|
-
`model_provider
|
|
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
|
|
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
|
|
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
|
|
984
|
-
own fenced `[model_providers.impel*]` block, leaving unrelated
|
|
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.
|
|
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
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 =
|
|
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
|
package/src/claudeSandbox.js
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
141
|
+
|
|
142
|
+
return restoreClaudeNetworkPermission(settings, snapshot) || changed;
|
|
80
143
|
}
|
package/src/codexSecurity.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
}
|
package/src/commands/use.js
CHANGED
|
@@ -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
|
}
|
package/src/gateway/index.js
CHANGED
|
@@ -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
|
|