impel-cli 0.16.0 → 0.16.2
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 +32 -7
- package/package.json +1 -1
- package/src/apps.js +76 -6
- package/src/claudeSandbox.js +80 -0
- package/src/claudeSetup.js +13 -0
- package/src/cliProfiles.js +2 -0
- package/src/commands/setup.js +4 -0
- package/src/commands/use.js +7 -0
- package/src/gateway/index.js +2 -0
- package/src/installRecovery/checkpoint.js +3 -0
- package/src/installRecovery/engine.js +10 -3
- package/src/installRecovery/redact.js +14 -3
- package/src/installRecovery/tools.js +67 -0
package/README.md
CHANGED
|
@@ -551,19 +551,37 @@ 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 12 makes existing tenant profiles
|
|
558
|
+
refresh onto this policy after the CLI update.
|
|
559
|
+
|
|
554
560
|
### `impel use gateway [claude|codex|all]` (alias `impel on`)
|
|
555
561
|
|
|
556
562
|
Turns **on** gateway mode for the given tool (default `all`). Requires `impel auth`
|
|
557
563
|
first.
|
|
558
564
|
|
|
559
565
|
**Claude Code** — merges into `~/.claude/settings.json` (creating it if needed,
|
|
560
|
-
leaving
|
|
566
|
+
leaving permissions, hooks, other env vars, and custom sandbox filesystem/network
|
|
567
|
+
rules untouched). Gateway mode enables Claude's strict auto-allow sandbox by
|
|
568
|
+
default: Bash runs inside the OS sandbox without routine prompts, the
|
|
569
|
+
`dangerouslyDisableSandbox` escape hatch is disabled, and supported platforms
|
|
570
|
+
fail closed if isolation cannot initialize. Native Windows keeps startup
|
|
571
|
+
available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
|
|
561
572
|
|
|
562
573
|
```json
|
|
563
574
|
{
|
|
564
575
|
"apiKeyHelper": "impel token",
|
|
565
576
|
"env": {
|
|
566
577
|
"ANTHROPIC_BASE_URL": "https://gateway.useimpel.com/anthropic"
|
|
578
|
+
},
|
|
579
|
+
"sandbox": {
|
|
580
|
+
"enabled": true,
|
|
581
|
+
"failIfUnavailable": true,
|
|
582
|
+
"autoAllowBashIfSandboxed": true,
|
|
583
|
+
"allowUnsandboxedCommands": false,
|
|
584
|
+
"excludedCommands": []
|
|
567
585
|
}
|
|
568
586
|
}
|
|
569
587
|
```
|
|
@@ -597,9 +615,9 @@ refresh_interval_ms = 300000
|
|
|
597
615
|
```
|
|
598
616
|
|
|
599
617
|
Before overwriting anything, `impel` **backs up** what was there (any prior
|
|
600
|
-
`apiKeyHelper
|
|
601
|
-
value) into `~/.config/impel/config.json`, so `impel use account`
|
|
602
|
-
exactly. If `config.toml` already has a `[model_providers.impel]` table that
|
|
618
|
+
`apiKeyHelper`, `ANTHROPIC_BASE_URL`, Claude sandbox policy, and the prior Codex
|
|
619
|
+
`model_provider` value) into `~/.config/impel/config.json`, so `impel use account`
|
|
620
|
+
can restore it exactly. If `config.toml` already has a `[model_providers.impel]` table that
|
|
603
621
|
wasn't written by this tool, `impel` refuses to touch the file and tells you to
|
|
604
622
|
resolve it manually rather than risk emitting invalid/duplicate TOML.
|
|
605
623
|
|
|
@@ -908,7 +926,7 @@ impel status # see where each tool stands right now
|
|
|
908
926
|
```
|
|
909
927
|
|
|
910
928
|
How the clean revert is guaranteed: `impel on` records the pre-existing values
|
|
911
|
-
(your own `apiKeyHelper` / `ANTHROPIC_BASE_URL
|
|
929
|
+
(your own `apiKeyHelper` / `ANTHROPIC_BASE_URL` / Claude sandbox policy, and Codex's prior
|
|
912
930
|
`model_provider`) into `~/.config/impel/config.json` **before** overwriting them,
|
|
913
931
|
and only ever captures a real *account-mode* value (re-running `impel on` never
|
|
914
932
|
clobbers the backup with Impel's own values). `impel off` removes only keys that
|
|
@@ -916,8 +934,9 @@ still point at Impel and restores those backups — so a full `on` → `off`
|
|
|
916
934
|
round-trip leaves `settings.json` and `config.toml` byte-for-byte back to where
|
|
917
935
|
they started, with all your unrelated keys and tables intact.
|
|
918
936
|
|
|
919
|
-
If you'd rather revert by hand: delete `apiKeyHelper
|
|
920
|
-
`ANTHROPIC_BASE_URL
|
|
937
|
+
If you'd rather revert by hand: delete `apiKeyHelper`, the Impel
|
|
938
|
+
`ANTHROPIC_BASE_URL`, and the Impel-managed sandbox keys from
|
|
939
|
+
`~/.claude/settings.json`; delete the block between the
|
|
921
940
|
`# >>> impel-cli managed block ... >>>` / `# <<< impel-cli managed block <<<`
|
|
922
941
|
markers in `~/.codex/config.toml` and reset `model_provider`; and
|
|
923
942
|
`rm -rf ~/.config/impel` to forget the PAT + gateway entirely.
|
|
@@ -969,6 +988,12 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
|
|
|
969
988
|
- https://developers.openai.com/codex/auth
|
|
970
989
|
- https://developers.openai.com/codex/ide
|
|
971
990
|
|
|
991
|
+
## Sources consulted for the Claude sandbox config
|
|
992
|
+
|
|
993
|
+
- https://code.claude.com/docs/en/settings#sandbox-settings
|
|
994
|
+
- https://code.claude.com/docs/en/sandboxing#configure-the-sandbox-for-your-organization
|
|
995
|
+
- https://github.com/anthropics/claude-code/blob/v2.1.205/examples/settings/settings-bash-sandbox.json
|
|
996
|
+
|
|
972
997
|
## Sources consulted for concurrent tenant apps
|
|
973
998
|
|
|
974
999
|
- Electron v43.1.0 release: https://github.com/electron/electron/releases/tag/v43.1.0
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
secureManagedCodexHome,
|
|
11
11
|
} from "./codexSecurity.js";
|
|
12
12
|
import { normalizeTenantId } from "./tenants.js";
|
|
13
|
+
import { impelCliInvocation } from "./selfInvocation.js";
|
|
13
14
|
import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
14
15
|
import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
|
|
16
|
+
import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
|
|
15
17
|
|
|
16
18
|
export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
|
|
17
19
|
const CHATGPT_CONFIG_START = "# >>> impel app managed gateway >>>";
|
|
@@ -161,7 +163,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
161
163
|
|
|
162
164
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
163
165
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
164
|
-
export const CURRENT_CONFIG_VERSION =
|
|
166
|
+
export const CURRENT_CONFIG_VERSION = 12;
|
|
165
167
|
|
|
166
168
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
167
169
|
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
@@ -575,6 +577,7 @@ export function installManagedAppFiles({
|
|
|
575
577
|
if (target === "claude") {
|
|
576
578
|
migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
|
|
577
579
|
writeClaudeDefaultApp(paths);
|
|
580
|
+
writeClaudeCodeSettings(paths);
|
|
578
581
|
writeClaudeConfig(paths, config, models);
|
|
579
582
|
}
|
|
580
583
|
else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
|
|
@@ -858,6 +861,23 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
858
861
|
}, null, 2) + "\n", 0o600);
|
|
859
862
|
}
|
|
860
863
|
|
|
864
|
+
function writeClaudeCodeSettings(paths) {
|
|
865
|
+
const settingsPath = path.join(paths.claude.userData, "settings.json");
|
|
866
|
+
let settings = {};
|
|
867
|
+
if (fs.existsSync(settingsPath)) {
|
|
868
|
+
try {
|
|
869
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
870
|
+
} catch {
|
|
871
|
+
throw new Error(`Claude Code settings are not valid JSON: ${settingsPath}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
875
|
+
throw new Error(`Claude Code settings must contain a JSON object: ${settingsPath}`);
|
|
876
|
+
}
|
|
877
|
+
applyImpelClaudeSandbox(settings);
|
|
878
|
+
writeAtomic(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
|
|
879
|
+
}
|
|
880
|
+
|
|
861
881
|
function writeClaudeDefaultApp(paths) {
|
|
862
882
|
const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
|
|
863
883
|
let current = {};
|
|
@@ -925,9 +945,16 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
|
|
|
925
945
|
};
|
|
926
946
|
writeAtomic(paths.chatgpt.catalog, JSON.stringify(catalog, null, 2) + "\n", 0o600);
|
|
927
947
|
const auth = invocations?.auth || { command: paths.tokenHelper, args: null };
|
|
948
|
+
// Finder/Dock-launched apps inherit a minimal PATH without npm/nvm bin
|
|
949
|
+
// directories, so a PATH-relative "impel" never spawns there. Bake the
|
|
950
|
+
// absolute node + CLI invocation; the launch-time `app refresh` rewrites
|
|
951
|
+
// this config whenever those paths change.
|
|
952
|
+
const mcpInvocation = impelCliInvocation(
|
|
953
|
+
config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"]
|
|
954
|
+
);
|
|
928
955
|
const mcp = invocations?.mcp || {
|
|
929
|
-
command:
|
|
930
|
-
args:
|
|
956
|
+
command: mcpInvocation.command,
|
|
957
|
+
args: [...mcpInvocation.args],
|
|
931
958
|
};
|
|
932
959
|
|
|
933
960
|
const managedToml = [
|
|
@@ -1153,20 +1180,63 @@ function readTopLevelTomlString(toml, key) {
|
|
|
1153
1180
|
}
|
|
1154
1181
|
}
|
|
1155
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* Rewrite one tenant's app token helper with the current node/CLI paths.
|
|
1185
|
+
* Cheap and Impel-owned, so install recovery can use it to heal helpers whose
|
|
1186
|
+
* baked interpreter or checkout paths went stale.
|
|
1187
|
+
*/
|
|
1188
|
+
export function rewriteTenantTokenHelper(tenantId, homeDir = os.homedir()) {
|
|
1189
|
+
const paths = appPaths(homeDir, tenantId);
|
|
1190
|
+
if (!fs.existsSync(path.dirname(paths.tokenHelper))) return false;
|
|
1191
|
+
writeTokenHelper(paths.tokenHelper, tenantId);
|
|
1192
|
+
return true;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1156
1195
|
function writeTokenHelper(target, tenantId) {
|
|
1157
1196
|
const cliPath = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
|
|
1158
1197
|
const node = shellQuote(process.execPath);
|
|
1159
1198
|
const cli = shellQuote(cliPath);
|
|
1160
1199
|
const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
1161
|
-
const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
1162
1200
|
// The apps invoke this helper whenever they need auth, which makes it the
|
|
1163
1201
|
// one hook that fires on Finder/Dock launches too. Piggyback a detached,
|
|
1164
1202
|
// TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
|
|
1165
1203
|
// the managed apps keep themselves current without touching stdout — the
|
|
1166
1204
|
// caller consumes stdout as the token.
|
|
1205
|
+
//
|
|
1206
|
+
// The baked interpreter/CLI paths are only first choices: an nvm upgrade or
|
|
1207
|
+
// a moved checkout deletes them, and a helper that hardcodes them strands
|
|
1208
|
+
// every managed app with no auth (observed as "provider auth command exited
|
|
1209
|
+
// with status 1" outages). Resolve again at call time before giving up, and
|
|
1210
|
+
// prefer the global npm install next to whichever node is found so the
|
|
1211
|
+
// refresh keeps rewriting this helper with current paths.
|
|
1167
1212
|
const script = `#!/bin/sh
|
|
1168
|
-
|
|
1169
|
-
|
|
1213
|
+
# Managed by impel-cli; regenerated by \`impel app refresh\`.
|
|
1214
|
+
NODE=${node}
|
|
1215
|
+
CLI=${cli}
|
|
1216
|
+
if ! [ -x "$NODE" ]; then
|
|
1217
|
+
NODE="$(command -v node 2>/dev/null || true)"
|
|
1218
|
+
fi
|
|
1219
|
+
if ! [ -x "$NODE" ]; then
|
|
1220
|
+
for candidate in /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node; do
|
|
1221
|
+
if [ -x "$candidate" ]; then NODE="$candidate"; fi
|
|
1222
|
+
done
|
|
1223
|
+
fi
|
|
1224
|
+
if ! [ -x "$NODE" ]; then
|
|
1225
|
+
echo 'impel token helper: no usable node runtime found; re-run \`impel setup\`' >&2
|
|
1226
|
+
exit 1
|
|
1227
|
+
fi
|
|
1228
|
+
if ! [ -f "$CLI" ]; then
|
|
1229
|
+
CLI="$(dirname "$NODE")/../lib/node_modules/impel-cli/bin/impel.js"
|
|
1230
|
+
fi
|
|
1231
|
+
if ! [ -f "$CLI" ]; then
|
|
1232
|
+
CLI="$(command -v impel 2>/dev/null || true)"
|
|
1233
|
+
fi
|
|
1234
|
+
if [ -z "$CLI" ] || ! [ -f "$CLI" ]; then
|
|
1235
|
+
echo 'impel token helper: impel-cli not found; re-run \`impel setup\`' >&2
|
|
1236
|
+
exit 1
|
|
1237
|
+
fi
|
|
1238
|
+
("$NODE" "$CLI" app refresh --stale-only${tenantArgs} </dev/null >/dev/null 2>&1 &)
|
|
1239
|
+
exec "$NODE" "$CLI" token${tenantArgs}
|
|
1170
1240
|
`;
|
|
1171
1241
|
writeAtomic(target, script, 0o700);
|
|
1172
1242
|
}
|
|
@@ -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
|
+
}
|
package/src/claudeSetup.js
CHANGED
|
@@ -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
|
};
|
package/src/cliProfiles.js
CHANGED
|
@@ -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" &&
|
package/src/commands/setup.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { promptSecret, promptText } from "../prompt.js";
|
|
18
18
|
import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "../cliProfiles.js";
|
|
19
19
|
import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
20
|
+
import { rewriteTenantTokenHelper } from "../apps.js";
|
|
20
21
|
import { cmdApps } from "./apps.js";
|
|
21
22
|
import { prepareWindowsClis, windowsCliInstallCommands } from "../windowsSetup.js";
|
|
22
23
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
@@ -490,6 +491,9 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
490
491
|
crossAppModels: crossAppModelsEnabled(config),
|
|
491
492
|
});
|
|
492
493
|
ensureImpelCodexProfile(gatewayUrl, tenant.id);
|
|
494
|
+
// Also refresh the app token helper: a stale baked node/CLI
|
|
495
|
+
// path there breaks every managed app's auth.
|
|
496
|
+
rewriteTenantTokenHelper(tenant.id);
|
|
493
497
|
return true;
|
|
494
498
|
},
|
|
495
499
|
installVendorApp,
|
package/src/commands/use.js
CHANGED
|
@@ -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
|
}
|
package/src/gateway/index.js
CHANGED
|
@@ -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
|
}
|
|
@@ -36,8 +36,11 @@ export function saveInstallRecoveryCheckpoint(
|
|
|
36
36
|
) {
|
|
37
37
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
38
38
|
const temporary = `${filePath}.tmp-${process.pid}`;
|
|
39
|
+
fs.rmSync(temporary, { force: true });
|
|
40
|
+
// "wx" refuses to follow a pre-planted symlink at the predictable temp path.
|
|
39
41
|
fs.writeFileSync(temporary, `${JSON.stringify(checkpoint, null, 2)}\n`, {
|
|
40
42
|
mode: 0o600,
|
|
43
|
+
flag: "wx",
|
|
41
44
|
});
|
|
42
45
|
fs.renameSync(temporary, filePath);
|
|
43
46
|
try {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { createRecoveryInference, resolveRecoverySeat } from "./inference.js";
|
|
29
29
|
import {
|
|
30
30
|
installRecoveryFingerprint,
|
|
31
|
+
redactInstallRecoveryText,
|
|
31
32
|
sanitizeInstallFailureEnvelope,
|
|
32
33
|
} from "./redact.js";
|
|
33
34
|
import {
|
|
@@ -93,8 +94,10 @@ async function runGoals(goals) {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
function describeToolCall(name, input) {
|
|
97
|
+
// Model-supplied values render in approval prompts and logs; strip control
|
|
98
|
+
// sequences so a confused model cannot rewrite what the user is approving.
|
|
96
99
|
const parameters = Object.entries(input || {})
|
|
97
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
100
|
+
.map(([key, value]) => `${key}=${redactSecretText(String(value)).slice(0, 160)}`)
|
|
98
101
|
.join(", ");
|
|
99
102
|
return parameters ? `${name} (${parameters})` : name;
|
|
100
103
|
}
|
|
@@ -141,7 +144,9 @@ async function executeCall(session, name, input, reason = null) {
|
|
|
141
144
|
|
|
142
145
|
function buildFirstUserMessage(session, failure, deterministicSummary, goalReport) {
|
|
143
146
|
const goalLines = goalReport.results.map(
|
|
144
|
-
(goal) => `- ${goal.ok ? "pass" : "FAIL"} ${goal.id}: ${goal.description}${
|
|
147
|
+
(goal) => `- ${goal.ok ? "pass" : "FAIL"} ${goal.id}: ${goal.description}${
|
|
148
|
+
goal.detail ? ` — ${redactInstallRecoveryText(goal.detail).slice(0, 300)}` : ""
|
|
149
|
+
}`
|
|
145
150
|
);
|
|
146
151
|
const lines = [
|
|
147
152
|
"An `impel` installation step failed on this machine. Diagnose and repair it.",
|
|
@@ -359,7 +364,9 @@ export async function runInstallRecovery(options, overrides = {}) {
|
|
|
359
364
|
tool_use_id: call.id,
|
|
360
365
|
content: `Rejected: goal health checks still fail locally:\n${goalReport.results
|
|
361
366
|
.filter((goal) => !goal.ok)
|
|
362
|
-
.map((goal) => `- ${goal.id}: ${goal.description}${
|
|
367
|
+
.map((goal) => `- ${goal.id}: ${goal.description}${
|
|
368
|
+
goal.detail ? ` — ${redactInstallRecoveryText(goal.detail).slice(0, 300)}` : ""
|
|
369
|
+
}`)
|
|
363
370
|
.join("\n")}\nKeep repairing or report blocked.`,
|
|
364
371
|
is_error: true,
|
|
365
372
|
});
|
|
@@ -5,9 +5,15 @@ import { redactSecretText } from "../config.js";
|
|
|
5
5
|
|
|
6
6
|
const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
|
|
7
7
|
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/giu;
|
|
8
|
-
|
|
8
|
+
// Quote-aware and prefix-tolerant: catches `token=x`, `"token": "x"`,
|
|
9
|
+
// `ANTHROPIC_API_KEY=x`, and `Authorization: x` shapes alike.
|
|
10
|
+
const SECRET_ASSIGNMENT_RE = /([A-Za-z0-9_.-]*(?:api[_-]?key|authorization|passwords?|secrets?|tokens?|credentials?)["']?\s*[:=]\s*["']?)([^\s"',;]+)/giu;
|
|
11
|
+
// Common vendor credential formats that appear bare in logs with no key= prefix.
|
|
12
|
+
const VENDOR_TOKEN_RE = /\b(?:sk-[A-Za-z0-9-]{2,20}-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|ghu_[A-Za-z0-9]{8,}|ghs_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|glpat-[A-Za-z0-9_-]{8,}|xox[abposr]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,})\b/gu;
|
|
9
13
|
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu;
|
|
10
|
-
|
|
14
|
+
// Any per-user directory is PII, not just the current user's home.
|
|
15
|
+
const WINDOWS_USER_PATH_RE = /\b[A-Za-z]:[\\/](?:Users|Profiles)[\\/][^\\/\s"']+/giu;
|
|
16
|
+
const POSIX_USER_PATH_RE = /(?:^|[\s"'=:(])((?:\/Users|\/home)\/)([^\/\s"']+)/gu;
|
|
11
17
|
|
|
12
18
|
function escapeRegex(value) {
|
|
13
19
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
@@ -20,9 +26,14 @@ export function redactInstallRecoveryText(value, { homeDir = os.homedir() } = {}
|
|
|
20
26
|
}
|
|
21
27
|
return text
|
|
22
28
|
.replace(WINDOWS_USER_PATH_RE, "%USERPROFILE%")
|
|
29
|
+
.replace(POSIX_USER_PATH_RE, (_match, prefix, name, offset, whole) => {
|
|
30
|
+
const lead = whole[offset] === "/" ? "" : whole[offset];
|
|
31
|
+
return `${lead}${prefix}[USER]`.replace("//", "/");
|
|
32
|
+
})
|
|
23
33
|
.replace(JWT_RE, "[REDACTED TOKEN]")
|
|
24
34
|
.replace(BEARER_RE, "Bearer [REDACTED]")
|
|
25
|
-
.replace(SECRET_ASSIGNMENT_RE, (_match, name) => `${name}
|
|
35
|
+
.replace(SECRET_ASSIGNMENT_RE, (_match, name) => `${name}[REDACTED]`)
|
|
36
|
+
.replace(VENDOR_TOKEN_RE, "[REDACTED TOKEN]")
|
|
26
37
|
.replace(EMAIL_RE, "[REDACTED EMAIL]")
|
|
27
38
|
.slice(0, 12_000);
|
|
28
39
|
}
|
|
@@ -70,6 +70,12 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
|
|
|
70
70
|
"Run a synthetic authenticated probe against the Impel gateway. Reports reachable/rejected status; never sends user content.",
|
|
71
71
|
schema: {},
|
|
72
72
|
},
|
|
73
|
+
check_auth_helper: {
|
|
74
|
+
risk: "read",
|
|
75
|
+
description:
|
|
76
|
+
"Run the tenant's Impel-owned app token helper with a bounded timeout and classify the result (works, missing, stale interpreter/CLI path, or failing). Use when managed apps report auth/credential failures.",
|
|
77
|
+
schema: {},
|
|
78
|
+
},
|
|
73
79
|
check_health: {
|
|
74
80
|
risk: "read",
|
|
75
81
|
description:
|
|
@@ -297,6 +303,52 @@ function readProfile(input, context) {
|
|
|
297
303
|
});
|
|
298
304
|
}
|
|
299
305
|
|
|
306
|
+
function checkAuthHelper(context, io) {
|
|
307
|
+
if (!context.tenantId) {
|
|
308
|
+
return result("failed", "No tenant is selected; the app token helper has not been created yet.");
|
|
309
|
+
}
|
|
310
|
+
if (context.platform === "win32") {
|
|
311
|
+
return result("failed", "Windows managed apps do not use a shell token helper; check the CLI profiles instead.");
|
|
312
|
+
}
|
|
313
|
+
const helperPath = path.join(
|
|
314
|
+
CONFIG_DIR,
|
|
315
|
+
"apps",
|
|
316
|
+
"tenants",
|
|
317
|
+
context.tenantId,
|
|
318
|
+
"bin",
|
|
319
|
+
"token"
|
|
320
|
+
);
|
|
321
|
+
if (!fs.existsSync(helperPath)) {
|
|
322
|
+
return result("failed", "The tenant app token helper is missing; repair_impel_profile regenerates it.");
|
|
323
|
+
}
|
|
324
|
+
const run = io.spawnSync(helperPath, [], {
|
|
325
|
+
encoding: "utf8",
|
|
326
|
+
env: context.environment,
|
|
327
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
328
|
+
timeout: 20_000,
|
|
329
|
+
windowsHide: true,
|
|
330
|
+
});
|
|
331
|
+
if (run?.error) {
|
|
332
|
+
return result("failed", `The app token helper could not be executed (${run.error.code || run.error.message}).`);
|
|
333
|
+
}
|
|
334
|
+
if (run.status === 0 && /impel_/u.test(String(run.stdout || ""))) {
|
|
335
|
+
return result("succeeded", "The app token helper runs and emits a credential.");
|
|
336
|
+
}
|
|
337
|
+
const stderr = String(run.stderr || "");
|
|
338
|
+
const classified = /Cannot find module|MODULE_NOT_FOUND/iu.test(stderr)
|
|
339
|
+
? "its embedded impel-cli path no longer exists"
|
|
340
|
+
: /No such file or directory|not found|no usable node/iu.test(stderr)
|
|
341
|
+
? "its embedded node runtime path no longer exists"
|
|
342
|
+
: /SyntaxError/iu.test(stderr)
|
|
343
|
+
? "it points at an incompatible impel-cli checkout"
|
|
344
|
+
: "it exited without emitting a credential";
|
|
345
|
+
return result(
|
|
346
|
+
"failed",
|
|
347
|
+
`The app token helper is broken: ${classified}. repair_impel_profile rewrites it with current paths.`,
|
|
348
|
+
{ exitCode: run.status, output: stderr.slice(0, 2_000) }
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
300
352
|
async function probeGateway(context) {
|
|
301
353
|
if (typeof context.probeGateway !== "function") {
|
|
302
354
|
return result("failed", "Gateway verification is unavailable in this recovery context.");
|
|
@@ -517,6 +569,7 @@ export async function executeInstallRecoveryTool(name, rawInput, context = {}, d
|
|
|
517
569
|
case "inspect_processes": return inspectProcesses(resolvedContext, io);
|
|
518
570
|
case "check_binary": return checkBinary(input, resolvedContext, io);
|
|
519
571
|
case "read_profile": return readProfile(input, resolvedContext);
|
|
572
|
+
case "check_auth_helper": return checkAuthHelper(resolvedContext, io);
|
|
520
573
|
case "probe_gateway": return probeGateway(resolvedContext);
|
|
521
574
|
case "check_health": return checkHealth(resolvedContext);
|
|
522
575
|
case "repair_impel_profile": return repairImpelProfile(resolvedContext);
|
|
@@ -577,5 +630,19 @@ export function deterministicInstallRecoveryPlan(failure, platform = process.pla
|
|
|
577
630
|
reason: "Transient npm/network failures often succeed on a clean retry.",
|
|
578
631
|
});
|
|
579
632
|
}
|
|
633
|
+
if (/provider auth command|cannot find module|module_not_found|token helper/u.test(text)) {
|
|
634
|
+
plan.push(
|
|
635
|
+
{
|
|
636
|
+
tool: "check_auth_helper",
|
|
637
|
+
input: {},
|
|
638
|
+
reason: "The failure mentions the app auth helper; classify how it is broken.",
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
tool: "repair_impel_profile",
|
|
642
|
+
input: {},
|
|
643
|
+
reason: "Regenerating Impel-owned profiles rewrites the token helper with current paths.",
|
|
644
|
+
}
|
|
645
|
+
);
|
|
646
|
+
}
|
|
580
647
|
return plan;
|
|
581
648
|
}
|