Skip to content

Commit 9b75568

Browse files
committed
fix(env): prevent secret leakage to external applications
- Add getCleanEnvForLaunch() that strips ~/.kenv/.env secrets from env - Rewrite open.ts to spawn macOS .app executables directly (bypasses Launch Services) - Update exec() to use clean env by default, preventing secret inheritance - Use AppleScript for terminal opening (more reliable than open -a with path) - Switch VS Code action to use `code` CLI instead of `open -a` - Pass clean env to editor spawns in terminal.ts edit function
1 parent e2fac9c commit 9b75568

File tree

5 files changed

+323
-16
lines changed

5 files changed

+323
-16
lines changed

src/api/packages/open.ts

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,156 @@
1-
import open, { openApp } from "@johnlindquist/open"
2-
;(global as any).open = open
3-
global.openApp = openApp
1+
import originalOpen, { openApp as originalOpenApp, type Options } from "@johnlindquist/open"
2+
import { getCleanEnvForLaunch } from "../../platform/base.js"
3+
import { spawn, execSync } from "node:child_process"
4+
import { existsSync } from "node:fs"
5+
import { join, basename } from "node:path"
6+
7+
/**
8+
* Gets the executable path inside a macOS .app bundle.
9+
* Uses PlistBuddy to read CFBundleExecutable from Info.plist.
10+
*/
11+
const getAppExecutable = (appPath: string): string | null => {
12+
if (!appPath.endsWith(".app")) {
13+
return null
14+
}
15+
16+
const infoPlistPath = join(appPath, "Contents", "Info.plist")
17+
if (!existsSync(infoPlistPath)) {
18+
return null
19+
}
20+
21+
try {
22+
// Use PlistBuddy to read the executable name from Info.plist
23+
const executableName = execSync(
24+
`/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "${infoPlistPath}"`,
25+
{ encoding: "utf-8" }
26+
).trim()
27+
28+
if (!executableName) {
29+
return null
30+
}
31+
32+
const executablePath = join(appPath, "Contents", "MacOS", executableName)
33+
if (existsSync(executablePath)) {
34+
return executablePath
35+
}
36+
} catch {
37+
// Try to guess based on app name (common pattern)
38+
const appName = basename(appPath, ".app")
39+
const guessedPath = join(appPath, "Contents", "MacOS", appName)
40+
if (existsSync(guessedPath)) {
41+
return guessedPath
42+
}
43+
}
44+
45+
return null
46+
}
47+
48+
/**
49+
* Launches a macOS app by spawning its executable directly.
50+
* This ensures the app inherits our clean environment instead of getting
51+
* environment from launchd/Launch Services.
52+
*
53+
* The macOS `open -a` command uses Launch Services, which starts apps with
54+
* environment from launchd - NOT from the calling process. This is why
55+
* passing env to spawn('open', ...) doesn't work for GUI apps.
56+
*/
57+
const launchMacOSApp = (appPath: string, cleanEnv: Record<string, string>): void => {
58+
const executablePath = getAppExecutable(appPath)
59+
60+
if (executablePath) {
61+
// Spawn the executable directly - it WILL inherit the clean environment
62+
const child = spawn(executablePath, [], {
63+
env: cleanEnv,
64+
detached: true,
65+
stdio: "ignore"
66+
})
67+
child.unref()
68+
} else {
69+
// Fallback: use open command (env won't be inherited, but at least it works)
70+
const child = spawn("open", ["-a", appPath], {
71+
env: cleanEnv,
72+
detached: true,
73+
stdio: "ignore"
74+
})
75+
child.unref()
76+
}
77+
}
78+
79+
/**
80+
* Open a target (URL, file, or app) with a clean environment.
81+
*
82+
* For macOS .app bundles, this spawns the executable directly to ensure
83+
* the clean environment is inherited. For other targets (URLs, files),
84+
* it uses the original open package.
85+
*/
86+
const openWithCleanEnv = (target: string, options?: Options) => {
87+
const cleanEnv = getCleanEnvForLaunch()
88+
89+
// Check if this is a macOS .app bundle
90+
if (process.platform === "darwin" && target.endsWith(".app") && existsSync(target)) {
91+
launchMacOSApp(target, cleanEnv)
92+
// Return a fake promise that resolves to a fake child process for API compatibility
93+
return Promise.resolve({
94+
pid: 0,
95+
unref: () => {},
96+
ref: () => {},
97+
kill: () => true
98+
} as unknown as ReturnType<typeof originalOpen>)
99+
}
100+
101+
// For non-.app targets, use the original open package with clean env
102+
return originalOpen(target, {
103+
...options,
104+
env: cleanEnv
105+
})
106+
}
107+
108+
/**
109+
* Open an app by name with a clean environment.
110+
*
111+
* For macOS, resolves the app name to a path and spawns the executable directly.
112+
*/
113+
const openAppWithCleanEnv = async (
114+
name: string | readonly string[],
115+
options?: Parameters<typeof originalOpenApp>[1]
116+
) => {
117+
const cleanEnv = getCleanEnvForLaunch()
118+
119+
if (process.platform === "darwin") {
120+
// Handle array of app names (try each until one works)
121+
const appNames = Array.isArray(name) ? name : [name]
122+
123+
for (const appName of appNames) {
124+
// Common app locations on macOS
125+
const possiblePaths = [
126+
`/Applications/${appName}.app`,
127+
`/Applications/${appName}`,
128+
`/System/Applications/${appName}.app`,
129+
`/System/Applications/${appName}`,
130+
`${process.env.HOME}/Applications/${appName}.app`,
131+
`${process.env.HOME}/Applications/${appName}`
132+
]
133+
134+
for (const appPath of possiblePaths) {
135+
if (existsSync(appPath) && appPath.endsWith(".app")) {
136+
launchMacOSApp(appPath, cleanEnv)
137+
return Promise.resolve({
138+
pid: 0,
139+
unref: () => {},
140+
ref: () => {},
141+
kill: () => true
142+
} as unknown as ReturnType<typeof originalOpenApp>)
143+
}
144+
}
145+
}
146+
}
147+
148+
// Fallback to original openApp with clean env
149+
return originalOpenApp(name, {
150+
...options,
151+
env: cleanEnv
152+
} as Parameters<typeof originalOpenApp>[1])
153+
}
154+
155+
;(global as any).open = openWithCleanEnv
156+
global.openApp = openAppWithCleanEnv

src/globals/execa.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as all from "execa"
22
import type { Options } from "execa"
3+
import { getCleanEnvForLaunch } from "../platform/base.js"
34

45
export let execa = all.execa
56
global.execa = execa
@@ -9,9 +10,26 @@ global.execaSync = execaSync
910

1011
export let execaCommand = all.execaCommand
1112
global.execaCommand = execaCommand
13+
14+
/**
15+
* Execute a shell command with a clean environment by default.
16+
*
17+
* The clean environment:
18+
* - Contains the user's normal shell environment (PATH, HOME, LANG, etc.)
19+
* - Does NOT contain Script Kit secrets from ~/.kenv/.env files
20+
* - Does NOT contain Kit-internal environment variables
21+
*
22+
* This prevents environment variable leakage when launching external applications
23+
* like terminals (iTerm, Terminal), editors (VS Code), or other GUI apps.
24+
*
25+
* To use the full process.env (including secrets), pass `env: process.env` in options.
26+
*/
1227
global.exec = ((command: string, options: Options = {}) => {
1328
const finalOptions: Options = {
1429
cwd: process.cwd(),
30+
// Use clean environment by default to prevent secret leakage
31+
// User can override by passing their own env in options
32+
env: getCleanEnvForLaunch(),
1533
...options,
1634
shell: options.shell ?? true,
1735
}

src/main/common.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,33 @@ export let actionFlags: ActionFlag[] = [
9696
}
9797

9898
if (isMac) {
99-
if (env?.KIT_TERMINAL?.toLowerCase() === 'iterm') {
100-
return await exec(`open -a iTerm '${selectedFile}'`)
101-
}
102-
return await exec(`open -a Terminal '${selectedFile}'`)
99+
// Note: Terminal apps will still load shell profiles (.zshrc/.bashrc) which may
100+
// source ~/.kenv/.env. To prevent secrets leaking to terminals, users should
101+
// guard .env sourcing in their shell profile: [ -n "$KIT" ] && source ~/.kenv/.env
102+
//
103+
// We use open() which spawns the executable directly with clean env, but the
104+
// terminal's shell session will read profiles independently.
105+
const terminalApp = env?.KIT_TERMINAL?.toLowerCase() === 'iterm'
106+
? '/Applications/iTerm.app'
107+
: '/Applications/Utilities/Terminal.app'
108+
109+
// Use AppleScript to open terminal at directory (more reliable than open -a with path)
110+
const script = env?.KIT_TERMINAL?.toLowerCase() === 'iterm'
111+
? `tell application "iTerm"
112+
activate
113+
tell current window
114+
create tab with default profile
115+
tell current session
116+
write text "cd '${selectedFile}'"
117+
end tell
118+
end tell
119+
end tell`
120+
: `tell application "Terminal"
121+
activate
122+
do script "cd '${selectedFile}'"
123+
end tell`
124+
125+
return await exec(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`)
103126
}
104127

105128
// Linux support
@@ -125,11 +148,9 @@ export let actionFlags: ActionFlag[] = [
125148
shortcut: `${cmd}+shift+v`,
126149
action: async (selectedFile) => {
127150
hide()
128-
if (isMac) {
129-
await exec(`open -a 'Visual Studio Code' '${selectedFile}'`)
130-
} else {
131-
await exec(`code ${selectedFile}`)
132-
}
151+
// Use `code` CLI on all platforms - it inherits our clean env from exec()
152+
// Avoid `open -a` on macOS as it goes through Launch Services and ignores env
153+
await exec(`code '${selectedFile}'`)
133154
}
134155
},
135156
...(process.env?.KIT_OPEN_IN

src/platform/base.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,84 @@
33
* Provides clean environment handling for VS Code, terminals, and other apps
44
*/
55

6+
import { existsSync, readFileSync } from "node:fs"
7+
import { kenvPath } from "../core/resolvers.js"
8+
9+
// Cache for env keys to strip (parsed once from .env files)
10+
let cachedEnvKeysToStrip: Set<string> | null = null
11+
12+
/**
13+
* Parses .env file content and returns the keys defined in it
14+
*/
15+
const parseEnvKeys = (content: string): string[] => {
16+
return content
17+
.split("\n")
18+
.filter((line) => {
19+
const trimmed = line.trim()
20+
return trimmed && !trimmed.startsWith("#") && trimmed.includes("=")
21+
})
22+
.map((line) => line.split("=")[0].trim())
23+
.filter(Boolean)
24+
}
25+
26+
/**
27+
* Gets the set of environment variable keys defined in ~/.kenv/.env files
28+
* These are Script Kit secrets that should not leak to external applications
29+
*/
30+
const getEnvKeysToStrip = (): Set<string> => {
31+
if (cachedEnvKeysToStrip) {
32+
return cachedEnvKeysToStrip
33+
}
34+
35+
const keysToStrip = new Set<string>()
36+
const envFiles = [".env", ".env.local", ".env.development", ".env.production", ".env.kit"]
37+
38+
for (const file of envFiles) {
39+
try {
40+
const envPath = kenvPath(file)
41+
if (existsSync(envPath)) {
42+
const content = readFileSync(envPath, "utf-8")
43+
for (const key of parseEnvKeys(content)) {
44+
keysToStrip.add(key)
45+
}
46+
}
47+
} catch {
48+
// Ignore errors reading .env files
49+
}
50+
}
51+
52+
// Also strip Kit-specific internal variables
53+
const kitInternalVars = [
54+
"KIT",
55+
"KENV",
56+
"KIT_CONTEXT",
57+
"KIT_MAIN_SCRIPT",
58+
"KIT_APP_PATH",
59+
"KIT_CLEAN_SHELL_ENV",
60+
"KIT_DOTENV_PATH",
61+
"KIT_ACCESSIBILITY",
62+
"KIT_TERMINAL",
63+
"KIT_EDITOR",
64+
"KIT_OPEN_IN",
65+
"PATH_FROM_DOTENV",
66+
"PARSED_PATH"
67+
]
68+
69+
for (const key of kitInternalVars) {
70+
keysToStrip.add(key)
71+
}
72+
73+
cachedEnvKeysToStrip = keysToStrip
74+
return keysToStrip
75+
}
76+
77+
/**
78+
* Clears the cached env keys - useful for testing or when .env files change
79+
*/
80+
export const clearEnvKeysCache = (): void => {
81+
cachedEnvKeysToStrip = null
82+
}
83+
684
/**
785
* Gets a clean shell environment for launching external applications
886
*
@@ -65,4 +143,33 @@ export const getCleanEnvForExternalApp = (): Record<string, string> => {
65143
return cleanEnv
66144
}
67145

68-
export {}
146+
/**
147+
* Gets a clean environment for launching external applications
148+
*
149+
* This combines the clean shell environment (from user's shell config) with
150+
* explicit removal of any secrets/keys from ~/.kenv/.env files.
151+
*
152+
* This ensures that:
153+
* 1. External apps get the user's normal shell environment (PATH, LANG, etc.)
154+
* 2. Script Kit secrets (API keys, etc.) do NOT leak to external processes
155+
*
156+
* @returns Clean environment suitable for exec() calls that launch external apps
157+
*/
158+
export const getCleanEnvForLaunch = (): Record<string, string> => {
159+
// Start with the clean shell environment
160+
const baseEnv = getCleanEnvForExternalApp()
161+
162+
// Get keys that should be stripped (from .env files and Kit internals)
163+
const keysToStrip = getEnvKeysToStrip()
164+
165+
// Create final clean env by removing sensitive keys
166+
const cleanEnv: Record<string, string> = {}
167+
168+
for (const [key, value] of Object.entries(baseEnv)) {
169+
if (!keysToStrip.has(key)) {
170+
cleanEnv[key] = value
171+
}
172+
}
173+
174+
return cleanEnv
175+
}

0 commit comments

Comments
 (0)