@sayknow-cli/utils 0.7.11 → 0.7.12

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.
Files changed (49) hide show
  1. package/dist/types/broken-pipe.d.ts +4 -3
  2. package/dist/types/canonical-log-dir.d.ts +20 -0
  3. package/dist/types/cli-metadata.d.ts +33 -0
  4. package/dist/types/cli.d.ts +12 -0
  5. package/dist/types/crash-fingerprint.d.ts +82 -0
  6. package/dist/types/crash-journal.d.ts +84 -0
  7. package/dist/types/crash-redaction.d.ts +20 -0
  8. package/dist/types/dirs.d.ts +127 -36
  9. package/dist/types/env-file.d.ts +59 -0
  10. package/dist/types/env.d.ts +19 -1
  11. package/dist/types/error-classification.d.ts +12 -0
  12. package/dist/types/fetch-retry.d.ts +6 -3
  13. package/dist/types/header-value.d.ts +10 -0
  14. package/dist/types/index.d.ts +6 -0
  15. package/dist/types/logger.d.ts +22 -8
  16. package/dist/types/native-process.d.ts +12 -0
  17. package/dist/types/procmgr.d.ts +1 -35
  18. package/dist/types/safe-error.d.ts +20 -0
  19. package/dist/types/sanitize-text.d.ts +10 -0
  20. package/dist/types/shell-config.d.ts +58 -0
  21. package/dist/types/stream.d.ts +2 -1
  22. package/dist/types/which.d.ts +1 -1
  23. package/package.json +2 -2
  24. package/src/broken-pipe.ts +16 -4
  25. package/src/canonical-log-dir.ts +59 -0
  26. package/src/cli-metadata.ts +74 -0
  27. package/src/cli.ts +30 -2
  28. package/src/crash-fingerprint.ts +294 -0
  29. package/src/crash-journal.ts +292 -0
  30. package/src/crash-redaction.ts +558 -0
  31. package/src/dirs.ts +583 -165
  32. package/src/env-file.ts +326 -20
  33. package/src/env.ts +321 -35
  34. package/src/error-classification.ts +31 -0
  35. package/src/fetch-retry.ts +23 -10
  36. package/src/frontmatter.ts +28 -2
  37. package/src/glob.ts +13 -10
  38. package/src/header-value.ts +15 -0
  39. package/src/index.ts +6 -0
  40. package/src/logger.ts +252 -97
  41. package/src/native-process.ts +19 -0
  42. package/src/postmortem.ts +1 -55
  43. package/src/procmgr.ts +16 -212
  44. package/src/ptree.ts +3 -2
  45. package/src/safe-error.ts +40 -0
  46. package/src/sanitize-text.ts +14 -0
  47. package/src/shell-config.ts +329 -0
  48. package/src/stream.ts +32 -1
  49. package/src/which.ts +6 -5
package/src/procmgr.ts CHANGED
@@ -1,216 +1,18 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { Process, ProcessStatus } from "@sayknow-cli/natives";
4
1
  import type { Subprocess } from "bun";
5
- import { $pickCredentialEnv, $pickflag, filterProcessEnv } from "./env";
6
- import { $which } from "./which";
7
-
8
- export interface ShellConfig {
9
- shell: string;
10
- args: string[];
11
- env: Record<string, string>;
12
- prefix: string | undefined;
13
- }
14
- let cachedShellConfig: ShellConfig | null = null;
15
-
16
- /**
17
- * Strip disabled macOS malloc-stack-logging vars from `process.env` in place.
18
- *
19
- * macOS leaves `MallocStackLogging=0` (or similar) inherited by debug-attached
20
- * shells. Bun's libc init then prints `MallocStackLogging: can't turn off
21
- * malloc stack logging because it was not enabled.` to stderr for every
22
- * subprocess. Scrubbing once at startup means every child we spawn — bash,
23
- * bun subagents, plugin installs, ptree commands — inherits a clean env.
24
- */
25
- export function scrubProcessEnv(): void {
26
- delete process.env.MallocStackLogging;
27
- delete process.env.MallocStackLoggingNoCompact;
28
- }
29
-
30
- /**
31
- * Check if a shell binary is executable.
32
- */
33
- function isExecutable(path: string): boolean {
34
- try {
35
- fs.accessSync(path, fs.constants.X_OK);
36
- return true;
37
- } catch {
38
- return false;
39
- }
40
- }
41
-
42
- /**
43
- * Build the spawn environment (cached).
44
- *
45
- * `CI=true` is injected unless the documented `SKC_BASH_NO_CI` (or its legacy
46
- * `PI_BASH_NO_CI` / `CLAUDE_BASH_NO_CI` aliases) is set to a canonical truthy
47
- * flag value.
48
- */
49
- function buildSpawnEnv(shell: string): Record<string, string> {
50
- const noCI = $pickflag("SKC_BASH_NO_CI", "PI_BASH_NO_CI", "CLAUDE_BASH_NO_CI");
51
- const inherited = filterProcessEnv(Bun.env);
52
- delete inherited.SKC_SESSION_FILE;
53
- delete inherited.SKC_MANAGED_OWNER_TRANSCRIPT_PATH;
54
- return {
55
- ...inherited,
56
- SHELL: shell,
57
- GIT_EDITOR: "true",
58
- GPG_TTY: "not a tty",
59
- SKCCODE: "1",
60
- CLAUDECODE: "1",
61
- ...(noCI ? {} : { CI: "true" }),
62
- } as Record<string, string>;
63
- }
64
-
65
- /**
66
- * Get shell args, optionally including login shell flag.
67
- *
68
- * Honors the documented `SKC_BASH_NO_LOGIN` first, with `PI_BASH_NO_LOGIN` and
69
- * `CLAUDE_BASH_NO_LOGIN` as legacy aliases. Boolean-like values follow the
70
- * canonical flag contract (`1`/`Y`/`TRUE`/`YES`/`ON`, case-insensitive), so an
71
- * explicit `SKC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
72
- * is set to a truthy value.
73
- */
74
- function getShellArgs(): string[] {
75
- const noLogin = $pickflag("SKC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
76
- return noLogin ? ["-c"] : ["-l", "-c"];
77
- }
78
-
79
- /**
80
- * Get shell prefix for wrapping commands (profilers, strace, etc.).
81
- *
82
- * Resolved from trusted sources only. The prefix is interpolated ahead of every
83
- * bash command (`${prefix} ${command}`) and executed through the shell, so it is
84
- * an arbitrary-command-execution surface. `$env` merges the caller's
85
- * `cwd/.env`, which means repository content could otherwise set it; resolution
86
- * therefore goes through the non-project resolver (launching shell plus
87
- * SKC/user-owned `.env` files), matching how provider credentials are resolved.
88
- */
89
- function getShellPrefix(): string | undefined {
90
- return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
91
- }
92
-
93
- /**
94
- * Build full shell config from a shell path.
95
- */
96
- function buildConfig(shell: string): ShellConfig {
97
- return {
98
- shell,
99
- args: getShellArgs(),
100
- env: buildSpawnEnv(shell),
101
- prefix: getShellPrefix(),
102
- };
103
- }
104
-
105
- /**
106
- * Resolve a basic shell (bash or sh) as fallback.
107
- */
108
- export function resolveBasicShell(): string | undefined {
109
- for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
110
- const resolved = $which(name);
111
- if (resolved) return resolved;
112
- }
113
-
114
- if (process.platform !== "win32") {
115
- const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
116
- const candidates = ["bash", "sh"];
117
-
118
- for (const name of candidates) {
119
- for (const dir of searchPaths) {
120
- const fullPath = path.join(dir, name);
121
- if (fs.existsSync(fullPath)) return fullPath;
122
- }
123
- }
124
- }
125
-
126
- return undefined;
127
- }
128
-
129
- /**
130
- * Get shell configuration based on platform.
131
- * Resolution order:
132
- * 1. User-specified shellPath in settings.json
133
- * 2. On Windows: Git Bash in known locations, then bash on PATH
134
- * 3. On Unix: $SHELL if bash/zsh, then fallback paths
135
- * 4. Fallback: sh
136
- */
137
- export function getShellConfig(customShellPath?: string): ShellConfig {
138
- if (cachedShellConfig) {
139
- return cachedShellConfig;
140
- }
141
-
142
- // 1. Check user-specified shell path
143
- if (customShellPath) {
144
- if (fs.existsSync(customShellPath)) {
145
- cachedShellConfig = buildConfig(customShellPath);
146
- return cachedShellConfig;
147
- }
148
- throw new Error(
149
- `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.skc/agent/settings.json`,
150
- );
151
- }
152
-
153
- if (process.platform === "win32") {
154
- // 2. Try Git Bash in known locations
155
- const paths: string[] = [];
156
- const programFiles = Bun.env.ProgramFiles;
157
- if (programFiles) {
158
- paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
159
- }
160
- const programFilesX86 = Bun.env["ProgramFiles(x86)"];
161
- if (programFilesX86) {
162
- paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
163
- }
164
-
165
- for (const path of paths) {
166
- if (fs.existsSync(path)) {
167
- cachedShellConfig = buildConfig(path);
168
- return cachedShellConfig;
169
- }
170
- }
171
-
172
- // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
173
- const bashOnPath = $which("bash.exe");
174
- if (bashOnPath) {
175
- cachedShellConfig = buildConfig(bashOnPath);
176
- return cachedShellConfig;
177
- }
178
-
179
- throw new Error(
180
- `No bash shell found. Options:\n` +
181
- ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
182
- ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
183
- ` 3. Set shellPath in ~/.skc/agent/settings.json\n\n` +
184
- `Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
185
- );
186
- }
187
-
188
- // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
189
- const userShell = Bun.env.SHELL;
190
- const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
191
- if (isValidShell && isExecutable(userShell)) {
192
- cachedShellConfig = buildConfig(userShell);
193
- return cachedShellConfig;
194
- }
195
-
196
- // 4. Fallback: use basic shell
197
- const basicShell = resolveBasicShell();
198
- if (basicShell) {
199
- cachedShellConfig = buildConfig(basicShell);
200
- return cachedShellConfig;
201
- }
202
- cachedShellConfig = buildConfig("sh");
203
- return cachedShellConfig;
204
- }
205
-
206
- /**
207
- * Clear the memoized shell configuration so the next {@link getShellConfig}
208
- * call re-resolves the shell and re-reads the environment (shell selection and
209
- * the bash CI/login flags). Primarily for tests that vary those inputs.
210
- */
211
- export function resetShellConfigCache(): void {
212
- cachedShellConfig = null;
213
- }
2
+ import { nativeProcessBindings } from "./native-process";
3
+
4
+ // Shell configuration lives in the natives-free ./shell-config module so
5
+ // consumers that only need shell resolution (e.g. Settings.getShellConfig)
6
+ // can import it without materializing @sayknow-cli/natives (W5b S1/idle
7
+ // module-trace gate). Re-exported here for compatibility with existing
8
+ // procmgr consumers, which already depend on natives for process control.
9
+ export {
10
+ getShellConfig,
11
+ resetShellConfigCache,
12
+ resolveBasicShell,
13
+ type ShellConfig,
14
+ scrubProcessEnv,
15
+ } from "./shell-config";
214
16
 
215
17
  /**
216
18
  * Check if a process is running.
@@ -222,6 +24,7 @@ export function isPidRunning(pid: number | Subprocess): boolean {
222
24
  return true;
223
25
  }
224
26
 
27
+ const { Process, ProcessStatus } = nativeProcessBindings();
225
28
  return Process.fromPid(pid)?.status() === ProcessStatus.Running;
226
29
  }
227
30
 
@@ -233,5 +36,6 @@ export async function onProcessExit(proc: Subprocess | number, abortSignal?: Abo
233
36
  );
234
37
  }
235
38
 
39
+ const { Process } = nativeProcessBindings();
236
40
  return (await Process.fromPid(proc)?.waitForExit({ signal: abortSignal })) ?? true;
237
41
  }
package/src/ptree.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  * - Convenience helpers: captureText / execText, AbortSignal, timeouts.
8
8
  */
9
9
 
10
- import { Process } from "@sayknow-cli/natives";
11
10
  import type { Spawn, Subprocess } from "bun";
11
+ import { nativeProcessBindings } from "./native-process";
12
12
 
13
13
  type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
14
14
 
@@ -217,7 +217,8 @@ export class ChildProcess<In extends InMask = InMask> {
217
217
  kill(reason?: Exception) {
218
218
  if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason;
219
219
  if (!this.proc.killed)
220
- void Process.fromPid(this.proc.pid)
220
+ void nativeProcessBindings()
221
+ .Process.fromPid(this.proc.pid)
221
222
  ?.terminate()
222
223
  ?.catch(e => void e);
223
224
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Throw-proof error description.
3
+ *
4
+ * Hostile or exotic thrown values can make `String(value)` throw (a `toString`
5
+ * that throws, a revoked proxy, a null-prototype object) and a `message` getter
6
+ * can throw too. Logging or recording a failure must never become a second
7
+ * failure, so every failure path that stringifies a caught value uses this.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { safeErrorDescription } from "@sayknow-cli/utils";
12
+ *
13
+ * try {
14
+ * await risky();
15
+ * } catch (error) {
16
+ * logger.warn("risky failed", { error: safeErrorDescription(error) });
17
+ * }
18
+ * ```
19
+ */
20
+ export function safeErrorDescription(value: unknown): string {
21
+ let isError = false;
22
+ try {
23
+ isError = value instanceof Error;
24
+ } catch {
25
+ // Hostile proxies can throw from getPrototypeOf during instanceof.
26
+ }
27
+ if (isError) {
28
+ try {
29
+ const message = (value as { message?: unknown }).message;
30
+ if (typeof message === "string") return message;
31
+ } catch {
32
+ // Hostile error getters must not replace the primary failure.
33
+ }
34
+ }
35
+ try {
36
+ return String(value);
37
+ } catch {
38
+ return "<unprintable error>";
39
+ }
40
+ }
@@ -17,6 +17,7 @@ const ESC_CHAR = "\x1b";
17
17
  // Well-formed strings only need control/ANSI detection: C0 (excl. \t \n),
18
18
  // CR, DEL, and C1. ESC (0x1B) is in \x0B-\x1F.
19
19
  const CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
20
+ const DISPLAY_FORMAT_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
20
21
 
21
22
  const REPLACEMENT_CHAR = "\ufffd";
22
23
 
@@ -28,6 +29,19 @@ export function sanitizeText(text: string): string {
28
29
  return sanitizeWellFormedText(text);
29
30
  }
30
31
 
32
+ /**
33
+ * Sanitize untrusted text that must occupy exactly one rendered row.
34
+ *
35
+ * {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
36
+ * treats it as zero-width, so a value carrying line breaks can still inject
37
+ * extra rows and evade a single-line width budget. Flatten every CR/LF run to a
38
+ * single space before the usual control/ANSI strip, then remove directional
39
+ * format controls that can visually reorder an otherwise safe row.
40
+ */
41
+ export function sanitizeDisplayLine(text: string): string {
42
+ return sanitizeText(text.replace(/[\r\n]+/gu, " ")).replace(DISPLAY_FORMAT_RE, "");
43
+ }
44
+
31
45
  function sanitizeWellFormedText(text: string): string {
32
46
  CONTROL_RE.lastIndex = 0;
33
47
  if (CONTROL_RE.exec(text) === null) return text;
@@ -0,0 +1,329 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { canonicalEnvKey, getTrustedHomeDir } from "./dirs";
4
+ import {
5
+ $credentialEnv,
6
+ $inheritedEnv,
7
+ $pickCredentialEnv,
8
+ $pickflag,
9
+ filterProcessEnv,
10
+ startupProjectEnvSnapshot,
11
+ } from "./env";
12
+ import { $which } from "./which";
13
+
14
+ export interface ShellConfig {
15
+ shell: string;
16
+ args: string[];
17
+ env: Record<string, string>;
18
+ prefix: string | undefined;
19
+ }
20
+ let cachedShellConfig: ShellConfig | null = null;
21
+
22
+ /**
23
+ * Strip disabled macOS malloc-stack-logging vars from `process.env` in place.
24
+ *
25
+ * macOS leaves `MallocStackLogging=0` (or similar) inherited by debug-attached
26
+ * shells. Bun's libc init then prints `MallocStackLogging: can't turn off
27
+ * malloc stack logging because it was not enabled.` to stderr for every
28
+ * subprocess. Scrubbing once at startup means every child we spawn — bash,
29
+ * bun subagents, plugin installs, ptree commands — inherits a clean env.
30
+ */
31
+ export function scrubProcessEnv(): void {
32
+ delete process.env.MallocStackLogging;
33
+ delete process.env.MallocStackLoggingNoCompact;
34
+ }
35
+
36
+ /**
37
+ * Check if a shell binary is executable.
38
+ */
39
+ function isExecutable(path: string): boolean {
40
+ try {
41
+ fs.accessSync(path, fs.constants.X_OK);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Variables that make the shell, Bun, or git run code on their own. Bun's
50
+ * `BUN_OPTIONS` can preload code before a source-mode worker entrypoint; bash
51
+ * sources `BASH_ENV` (sh: `ENV`) before every non-interactive command, and zsh
52
+ * sources `$ZDOTDIR/.zshenv`. git applies `GIT_CONFIG_*`, reads config from
53
+ * `GIT_DIR`/`GIT_COMMON_DIR`, `$XDG_CONFIG_HOME/git/config` and `$HOME/.gitconfig`;
54
+ * on Windows it can also derive its home from `USERPROFILE`, `HOMEDRIVE` and
55
+ * `HOMEPATH`. It runs e.g. `core.fsmonitor` during `git status` and helpers from
56
+ * `GIT_EXEC_PATH`, `GIT_PROXY_COMMAND` for `git://` remotes,
57
+ * `GIT_EXTERNAL_DIFF`, `GIT_SSH(_COMMAND)`, `GIT_ASKPASS` (auth prompts, even
58
+ * with `GIT_TERMINAL_PROMPT=0`) and `GIT_SEQUENCE_EDITOR` (`git rebase -i`).
59
+ * `GIT_TEMPLATE_DIR` can also seed executable hooks when `git init` creates a
60
+ * repository. Interpreters the shell starts also load code named by the
61
+ * environment: Node `NODE_OPTIONS` (`--require`), Python `PYTHONPATH`
62
+ * (`sitecustomize`), `PYTHONUSERBASE` (`usercustomize`), and `PYTHONSTARTUP`
63
+ * (interactive startup), Perl `PERL5OPT`/`PERL5LIB` (`-M`), the JVM
64
+ * `JAVA_TOOL_OPTIONS`/`JDK_JAVA_OPTIONS` (`-javaagent`). The dynamic loader
65
+ * runs code for every dynamically linked command from `LD_PRELOAD` and `LD_AUDIT` modules, and
66
+ * resolves libraries from `LD_LIBRARY_PATH` first, so a project directory there
67
+ * can shadow a system library (macOS: `DYLD_INSERT_LIBRARIES` and the
68
+ * `DYLD_*_LIBRARY_PATH`/`DYLD_*_FRAMEWORK_PATH` search overrides). Sibling
69
+ * spellings load the same code: Perl `PERLLIB`, the JVM `_JAVA_OPTIONS`, and npm
70
+ * `npm_config_node_options` (matched separately below, in any case). Common tools also read programs from the
71
+ * environment: ripgrep `RIPGREP_CONFIG_PATH` (a config file that can add
72
+ * `--pre=<program>`), GNU tar `TAR_OPTIONS` (`--use-compress-program`,
73
+ * `--to-command`), rsync `RSYNC_RSH`, make `MAKEFLAGS` (`SHELL=`) and
74
+ * `MAKEFILES` (a makefile with `$(shell …)`), and bash `SHELLOPTS`, which turns on
75
+ * `xtrace` so `PS4` command substitutions run in every `bash -c`. A repository
76
+ * `.env` must not be able to set them, or bash tool commands could execute
77
+ * repository code.
78
+ */
79
+ const PROJECT_DENIED_SPAWN_ENV =
80
+ /^(?:BASH_ENV|BUN_OPTIONS|ENV|ZDOTDIR|HOME|USERPROFILE|HOMEDRIVE|HOMEPATH|XDG_CONFIG_HOME|GIT_DIR|GIT_COMMON_DIR|GIT_EXEC_PATH|GIT_EXTERNAL_DIFF|GIT_PROXY_COMMAND|GIT_SSH|GIT_SSH_COMMAND|GIT_ASKPASS|GIT_SEQUENCE_EDITOR|GIT_TEMPLATE_DIR|NODE_OPTIONS|PYTHONPATH|PYTHONUSERBASE|PYTHONSTARTUP|PERL5OPT|PERL5LIB|JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|_JAVA_OPTIONS|PERLLIB|RIPGREP_CONFIG_PATH|TAR_OPTIONS|RSYNC_RSH|MAKEFLAGS|MAKEFILES|SHELLOPTS|PS4|LD_PRELOAD|LD_AUDIT|LD_LIBRARY_PATH|DYLD_(?:INSERT_LIBRARIES|LIBRARY_PATH|FALLBACK_LIBRARY_PATH|FRAMEWORK_PATH|FALLBACK_FRAMEWORK_PATH|VERSIONED_LIBRARY_PATH|VERSIONED_FRAMEWORK_PATH)|GIT_CONFIG_(?:COUNT|PARAMETERS|GLOBAL|SYSTEM|KEY_\d+|VALUE_\d+))$/;
81
+ /**
82
+ * npm reads `npm_config_<key>` with the prefix in any case and normalizes the key
83
+ * (case and `_`/`-`), so `npm_config_Node_Options` and `npm_config_node-options`
84
+ * both set `node-options`, which npm hands to the Node processes it starts as
85
+ * `NODE_OPTIONS`, and `script-shell` picks the program every lifecycle script
86
+ * (`npm run`, `npm test`, install hooks) runs through.
87
+ */
88
+ const PROJECT_DENIED_NPM_SPAWN_ENV = /^npm_config_(?:node[_-]options|script[_-]shell)$/i;
89
+ const PROJECT_HOME_SPAWN_ENV = new Set(["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]);
90
+
91
+ /**
92
+ * Names of hook-capable variables whose live value came from the project `.env`.
93
+ *
94
+ * Provenance comes from `$inheritedEnv`, the same launch snapshot that guards
95
+ * credentials: a value the launching shell exported with a different value than
96
+ * the project declaration is kept. Bun does not reveal which source supplied a
97
+ * value, so an inherited value the project repeats verbatim, or a key the project
98
+ * declares dynamically, is ambiguous and fails closed (dropped).
99
+ */
100
+ export function projectDeclaredSpawnHookNames(env: Record<string, string | undefined> = Bun.env): string[] {
101
+ const snapshot = startupProjectEnvSnapshot();
102
+ const names: string[] = [];
103
+ for (const name of Object.keys(env)) {
104
+ const key = canonicalEnvKey(name);
105
+ const dynamic = snapshot.dynamic.has(key);
106
+ const denied = PROJECT_DENIED_SPAWN_ENV.test(key) || PROJECT_DENIED_NPM_SPAWN_ENV.test(key);
107
+ if (!denied || (snapshot.values[key] === undefined && !dynamic)) continue;
108
+ if (env[name] === undefined || (!dynamic && $inheritedEnv(name) !== undefined)) continue;
109
+ names.push(name);
110
+ }
111
+ return names;
112
+ }
113
+
114
+ /** The account home SKC trusts, or undefined when none is trustworthy. */
115
+ function trustedHome(): string | undefined {
116
+ try {
117
+ return getTrustedHomeDir();
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Remove project-declared hook variables from a spawn environment in place.
125
+ * If removing a home selector leaves `HOME` unset, provide the trusted account
126
+ * home when one exists rather than allowing a Windows profile fallback.
127
+ */
128
+ export function stripProjectSpawnHooks(env: Record<string, string>): Record<string, string> {
129
+ const removed = projectDeclaredSpawnHookNames(env);
130
+ for (const name of removed) delete env[name];
131
+ const removedHomeSelector = removed.some(name => PROJECT_HOME_SPAWN_ENV.has(canonicalEnvKey(name)));
132
+ if ((env.HOME === undefined || env.HOME === "") && (process.platform !== "win32" || removedHomeSelector)) {
133
+ const home = trustedHome();
134
+ if (home !== undefined) env.HOME = home;
135
+ else delete env.HOME;
136
+ }
137
+ return env;
138
+ }
139
+
140
+ /**
141
+ * The process environment without project-declared hook variables, for spawning
142
+ * the bash tool's shell processes (isolated worker chain and PTY sessions).
143
+ */
144
+ export function projectSafeSpawnEnv(): Record<string, string> {
145
+ return stripProjectSpawnHooks(filterProcessEnv(Bun.env));
146
+ }
147
+
148
+ /** Provide the trusted HOME override needed when PTY hook filtering removes a home selector. */
149
+ export function projectSafeHomeOverride(explicit?: Record<string, string>): Record<string, string> {
150
+ if (Object.hasOwn(explicit ?? {}, "HOME")) return {};
151
+ const removesHomeSelector = projectDeclaredSpawnHookNames().some(
152
+ name => !Object.hasOwn(explicit ?? {}, name) && PROJECT_HOME_SPAWN_ENV.has(canonicalEnvKey(name)),
153
+ );
154
+ if (!removesHomeSelector) return {};
155
+ const home = projectSafeSpawnEnv().HOME;
156
+ return home === undefined ? {} : { HOME: home };
157
+ }
158
+
159
+ /**
160
+ * Build the spawn environment (cached).
161
+ *
162
+ * The spawn environment is the inherited process environment plus the fixed
163
+ * hardening entries below. `CI` is not injected: a process that did not export
164
+ * it must not look like CI to the tools it runs.
165
+ */
166
+ function buildSpawnEnv(shell: string): Record<string, string> {
167
+ const inherited = filterProcessEnv(Bun.env);
168
+ delete inherited.SKC_SESSION_FILE;
169
+ delete inherited.SKC_MANAGED_OWNER_TRANSCRIPT_PATH;
170
+ return {
171
+ ...inherited,
172
+ SHELL: shell,
173
+ GIT_EDITOR: "true",
174
+ GPG_TTY: "not a tty",
175
+ SKCCODE: "1",
176
+ CLAUDECODE: "1",
177
+ } as Record<string, string>;
178
+ }
179
+
180
+ /**
181
+ * Get shell args, optionally including login shell flag.
182
+ *
183
+ * Honors the documented `SKC_BASH_NO_LOGIN` first, with `PI_BASH_NO_LOGIN` and
184
+ * `CLAUDE_BASH_NO_LOGIN` as legacy aliases. Boolean-like values follow the
185
+ * canonical flag contract (`1`/`Y`/`TRUE`/`YES`/`ON`, case-insensitive), so an
186
+ * explicit `SKC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
187
+ * is set to a truthy value.
188
+ */
189
+ function getShellArgs(): string[] {
190
+ const noLogin = $pickflag("SKC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
191
+ return noLogin ? ["-c"] : ["-l", "-c"];
192
+ }
193
+
194
+ /**
195
+ * Get shell prefix for wrapping commands (profilers, strace, etc.).
196
+ *
197
+ * Resolved from trusted sources only. The prefix is interpolated ahead of every
198
+ * bash command (`${prefix} ${command}`) and executed through the shell, so it is
199
+ * an arbitrary-command-execution surface. `$env` merges the caller's
200
+ * `cwd/.env`, which means repository content could otherwise set it; resolution
201
+ * therefore goes through the non-project resolver (launching shell plus
202
+ * SKC/user-owned `.env` files), matching how provider credentials are resolved.
203
+ */
204
+ function getShellPrefix(): string | undefined {
205
+ return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
206
+ }
207
+
208
+ /**
209
+ * Build full shell config from a shell path.
210
+ */
211
+ function buildConfig(shell: string): ShellConfig {
212
+ return {
213
+ shell,
214
+ args: getShellArgs(),
215
+ env: buildSpawnEnv(shell),
216
+ prefix: getShellPrefix(),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Resolve a basic shell (bash or sh) as fallback.
222
+ */
223
+ export function resolveBasicShell(): string | undefined {
224
+ for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
225
+ const resolved = $which(name);
226
+ if (resolved) return resolved;
227
+ }
228
+
229
+ if (process.platform !== "win32") {
230
+ const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
231
+ const candidates = ["bash", "sh"];
232
+
233
+ for (const name of candidates) {
234
+ for (const dir of searchPaths) {
235
+ const fullPath = path.join(dir, name);
236
+ if (fs.existsSync(fullPath)) return fullPath;
237
+ }
238
+ }
239
+ }
240
+
241
+ return undefined;
242
+ }
243
+
244
+ /**
245
+ * Get shell configuration based on platform.
246
+ * Resolution order:
247
+ * 1. User-specified shellPath in settings.json
248
+ * 2. On Windows: Git Bash in known locations, then bash on PATH
249
+ * 3. On Unix: $SHELL if bash/zsh, then fallback paths
250
+ * 4. Fallback: sh
251
+ */
252
+ export function getShellConfig(customShellPath?: string): ShellConfig {
253
+ if (cachedShellConfig) {
254
+ return cachedShellConfig;
255
+ }
256
+
257
+ // 1. Check user-specified shell path
258
+ if (customShellPath) {
259
+ if (fs.existsSync(customShellPath)) {
260
+ cachedShellConfig = buildConfig(customShellPath);
261
+ return cachedShellConfig;
262
+ }
263
+ throw new Error(
264
+ `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.skc/agent/settings.json`,
265
+ );
266
+ }
267
+
268
+ if (process.platform === "win32") {
269
+ // 2. Try Git Bash in known locations
270
+ const paths: string[] = [];
271
+ const programFiles = Bun.env.ProgramFiles;
272
+ if (programFiles) {
273
+ paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
274
+ }
275
+ const programFilesX86 = Bun.env["ProgramFiles(x86)"];
276
+ if (programFilesX86) {
277
+ paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
278
+ }
279
+
280
+ for (const path of paths) {
281
+ if (fs.existsSync(path)) {
282
+ cachedShellConfig = buildConfig(path);
283
+ return cachedShellConfig;
284
+ }
285
+ }
286
+
287
+ // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
288
+ const bashOnPath = $which("bash.exe");
289
+ if (bashOnPath) {
290
+ cachedShellConfig = buildConfig(bashOnPath);
291
+ return cachedShellConfig;
292
+ }
293
+
294
+ throw new Error(
295
+ `No bash shell found. Options:\n` +
296
+ ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
297
+ ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
298
+ ` 3. Set shellPath in ~/.skc/agent/settings.json\n\n` +
299
+ `Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
300
+ );
301
+ }
302
+
303
+ // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
304
+ // Bun fills an unset SHELL from cwd/.env, so read only the launching shell's value.
305
+ const userShell = $credentialEnv("SHELL");
306
+ const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
307
+ if (isValidShell && isExecutable(userShell)) {
308
+ cachedShellConfig = buildConfig(userShell);
309
+ return cachedShellConfig;
310
+ }
311
+
312
+ // 4. Fallback: use basic shell
313
+ const basicShell = resolveBasicShell();
314
+ if (basicShell) {
315
+ cachedShellConfig = buildConfig(basicShell);
316
+ return cachedShellConfig;
317
+ }
318
+ cachedShellConfig = buildConfig("sh");
319
+ return cachedShellConfig;
320
+ }
321
+
322
+ /**
323
+ * Clear the memoized shell configuration so the next {@link getShellConfig}
324
+ * call re-resolves the shell and re-reads the environment (shell selection and
325
+ * the bash CI/login flags). Primarily for tests that vary those inputs.
326
+ */
327
+ export function resetShellConfigCache(): void {
328
+ cachedShellConfig = null;
329
+ }