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 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 every other key — permissions, hooks, other env vars untouched):
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` / `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
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`, and Codex's prior
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` + the Impel
920
- `ANTHROPIC_BASE_URL` from `~/.claude/settings.json`; delete the block between the
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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
@@ -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 = 11;
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: "impel",
930
- args: config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"],
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
- (${node} ${cli} app refresh --stale-only${refreshTenantArgs} </dev/null >/dev/null 2>&1 &)
1169
- exec ${node} ${cli} token${tenantArgs}
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
+ }
@@ -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" &&
@@ -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,
@@ -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
  }
@@ -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}${goal.detail ? ` — ${goal.detail}` : ""}`
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}${goal.detail ? ` — ${goal.detail}` : ""}`)
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
- const SECRET_ASSIGNMENT_RE = /\b(api[_-]?key|authorization|password|secret|token)\s*[:=]\s*([^\s,;]+)/giu;
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
- const WINDOWS_USER_PATH_RE = /\b[A-Za-z]:\\Users\\[^\\\s]+/giu;
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}=[REDACTED]`)
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
  }