@fnclaude/cli 0.7.5 → 0.7.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fnclaude/cli",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "fnclaude CLI implementation (TypeScript rewrite, in progress)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/argParser.ts CHANGED
@@ -177,7 +177,7 @@ export function parseArgs(argv: readonly string[], home: string): ParsedArgs {
177
177
  const passthrough: string[] = [];
178
178
  let noTmux = false;
179
179
  let worktreeSet = false;
180
- let worktreeArg = '';
180
+ let worktreeArg: string | undefined;
181
181
 
182
182
  // Magic slots: filled at most once each, in strict order.
183
183
  let magicModel = '';
@@ -106,6 +106,12 @@ export function preserveArgs(
106
106
  while (i < origArgs.length) {
107
107
  const tok = origArgs[i] as string;
108
108
 
109
+ // `--` separates flags from the original session's initial prompt
110
+ // (everything after is the prompt body). Carrying it across a
111
+ // relaunch shadows the transfer's @summary file or re-prompts after
112
+ // --resume on restart — drop the separator and the entire tail.
113
+ if (tok === '--') break;
114
+
109
115
  // Equals-form (--flag=value): match by the flag-prefix-before-= part.
110
116
  if (deny !== null) {
111
117
  const eq = tok.indexOf('=');
package/src/args.ts CHANGED
@@ -75,9 +75,9 @@ export interface BaseArgs {
75
75
 
76
76
  /**
77
77
  * WorktreeArg is the name/value given with -w / --worktree (or the 2nd
78
- * positional / +workspace suffix), or "" if the flag was bare.
78
+ * positional / +workspace suffix), or undefined if the flag was bare.
79
79
  */
80
- readonly worktreeArg: string;
80
+ readonly worktreeArg: string | undefined;
81
81
 
82
82
  /**
83
83
  * UsedNoopFallback is true when CWD was filled by the noop fallback (no
package/src/autoname.ts CHANGED
@@ -59,15 +59,15 @@ export function shouldAutoName(passthrough: readonly string[]): boolean {
59
59
 
60
60
  /**
61
61
  * extractPrompt returns the first non-empty token after "--" in passthrough.
62
- * Returns "" if not found.
62
+ * Returns undefined if not found.
63
63
  */
64
- export function extractPrompt(passthrough: readonly string[]): string {
64
+ export function extractPrompt(passthrough: readonly string[]): string | undefined {
65
65
  const sepIdx = passthrough.indexOf('--');
66
- if (sepIdx < 0) return '';
66
+ if (sepIdx < 0) return undefined;
67
67
  for (const t of passthrough.slice(sepIdx + 1)) {
68
68
  if (t !== '') return t;
69
69
  }
70
- return '';
70
+ return undefined;
71
71
  }
72
72
 
73
73
  // ── stop-words + heuristic fallback ────────────────────────────────────────
@@ -123,9 +123,9 @@ const RE_WHITESPACE = /\s+/g;
123
123
 
124
124
  /**
125
125
  * sanitizeSlug cleans raw LLM output into a valid kebab slug (up to 3 dash-
126
- * separated segments). Returns "" when nothing survives sanitization.
126
+ * separated segments). Returns undefined when nothing survives sanitization.
127
127
  */
128
- export function sanitizeSlug(raw: string): string {
128
+ export function sanitizeSlug(raw: string): string | undefined {
129
129
  let s = raw.trim().toLowerCase();
130
130
  s = s.replace(RE_WHITESPACE, '-');
131
131
  s = s.replace(RE_NON_SLUG, '');
@@ -136,7 +136,7 @@ export function sanitizeSlug(raw: string): string {
136
136
  s = parts.slice(0, 3).join('-');
137
137
  // Trim again in case joining re-introduced edge dashes.
138
138
  s = s.replace(/^-+|-+$/g, '');
139
- return s;
139
+ return s !== '' ? s : undefined;
140
140
  }
141
141
 
142
142
  // ── LLM client abstraction ──────────────────────────────────────────────────
@@ -235,11 +235,12 @@ export function claudeCliFn(model: string, spawnFn: SpawnFn = defaultSpawnFn): L
235
235
  * On any error the function falls back to heuristicName silently.
236
236
  */
237
237
  export async function generateName(
238
- prompt: string,
238
+ prompt: string | undefined,
239
239
  cfg: NameConfig,
240
240
  apiKey: string,
241
241
  llmFn?: LlmClientFn,
242
242
  ): Promise<string> {
243
+ const resolved = prompt ?? '';
243
244
  let usingCLI = false;
244
245
  if (!llmFn) {
245
246
  if (apiKey) {
@@ -261,11 +262,11 @@ export async function generateName(
261
262
  const timer = setTimeout(() => controller.abort(), timeoutMs);
262
263
 
263
264
  try {
264
- const raw = await llmFn(cfg.model, prompt, controller.signal);
265
+ const raw = await llmFn(cfg.model, resolved, controller.signal);
265
266
  const name = sanitizeSlug(raw);
266
- return name !== '' ? name : heuristicName(prompt);
267
+ return name !== undefined ? name : heuristicName(resolved);
267
268
  } catch {
268
- return heuristicName(prompt);
269
+ return heuristicName(resolved);
269
270
  } finally {
270
271
  clearTimeout(timer);
271
272
  }
@@ -1,27 +1,34 @@
1
- // Port of src/host_aliases.go (fnclaude/fnclaude Go reference).
1
+ // Load the {host-short} alias map.
2
2
  //
3
- // Load the {host-short} alias map from up to two layered files. System file
4
- // ships with fnclaude (canonical defaults, root-owned, regenerated on
5
- // upgrade); user file optionally overrides per-key. Both files are JSON
6
- // objects mapping fully-qualified host → short alias.
3
+ // Builtin defaults (BUILTIN_HOST_ALIASES) ship with the package npm install
4
+ // has no hook to drop a JSON file under /usr/share, so the canonical 4 host
5
+ // mappings live in source. Users override per-key via
6
+ // ~/.local/share/fnrhombus/host-aliases.json.
7
7
  //
8
- // Missing files are silently treated as empty maps. If a user template uses
8
+ // Missing user file is silently treated as empty. If a user template uses
9
9
  // {host-short} and the merged map has no entry for the current host, the
10
10
  // substitution step calls missingHostShortError() to produce a message
11
- // naming both file paths and a copy-pasteable JSON example.
11
+ // naming the user file path and a copy-pasteable JSON example.
12
12
 
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
 
17
17
  /**
18
- * Install-dir LUT, owned by the fnclaude package. The AUR PKGBUILD installs
19
- * the file here with sensible defaults.
18
+ * Bundled host-alias defaults. These ship with the package and are
19
+ * always present; the user file (if any) overrides per key.
20
+ *
21
+ * Kept in sync with the Go reference's AUR PKGBUILD install fixture.
20
22
  */
21
- export const HOST_ALIASES_SYSTEM_PATH = '/usr/share/fnrhombus/host-aliases.json';
23
+ export const BUILTIN_HOST_ALIASES: Readonly<Record<string, string>> = Object.freeze({
24
+ 'github.com': 'gh',
25
+ 'gitlab.com': 'gl',
26
+ 'bitbucket.org': 'bb',
27
+ 'codeberg.org': 'cb',
28
+ });
22
29
 
23
30
  /**
24
- * Per-user override path. Wins per-key against the system file.
31
+ * Per-user override path. Wins per-key against the builtin defaults.
25
32
  */
26
33
  export function hostAliasesUserPath(home: string): string {
27
34
  return join(home, '.local', 'share', 'fnrhombus', 'host-aliases.json');
@@ -39,17 +46,22 @@ export interface LoadHostAliasesResult {
39
46
  }
40
47
 
41
48
  /**
42
- * Read both files (if present) and merge them, user-level winning per
43
- * key. Either or both missing returns whatever is available (or empty).
49
+ * Return the merged alias map: builtin defaults overlaid with the user
50
+ * file (if present). User entries win per key. Missing user file is the
51
+ * common path and stays silent.
44
52
  */
45
53
  export function loadHostAliases(home: string): LoadHostAliasesResult {
46
- return mergeHostAliases([HOST_ALIASES_SYSTEM_PATH, hostAliasesUserPath(home)]);
54
+ const userResult = mergeHostAliases([hostAliasesUserPath(home)]);
55
+ const merged: Record<string, string> = { ...BUILTIN_HOST_ALIASES };
56
+ for (const k of Object.keys(userResult.aliases)) {
57
+ merged[k] = userResult.aliases[k] as string;
58
+ }
59
+ return { aliases: merged, warnings: userResult.warnings };
47
60
  }
48
61
 
49
62
  /**
50
63
  * Read each path (if it exists) and merge per-key with later entries
51
- * winning over earlier ones. Callers order system-first, user-second so
52
- * user wins on conflict.
64
+ * winning over earlier ones.
53
65
  */
54
66
  export function mergeHostAliases(paths: string[]): LoadHostAliasesResult {
55
67
  const merged: Record<string, string> = {};
@@ -106,9 +118,9 @@ export function readHostAliasesFile(path: string): ReadHostAliasesFileResult {
106
118
 
107
119
  /**
108
120
  * Build the error emitted when a template uses {host-short} but no alias
109
- * is configured for the current host. Same message shape as the JS
110
- * plugin's missingHostShortError so users see consistent guidance from
111
- * either consumer.
121
+ * is configured for the current host. Points only at the user file path
122
+ * since builtins already cover the common forges and any further
123
+ * overrides go in the user file.
112
124
  *
113
125
  * `home` is injected so callers in tests can pin the user-path output
114
126
  * without touching $HOME; production callers should pass `process.env.HOME
@@ -119,9 +131,8 @@ export function missingHostShortError(host: string, home?: string): Error {
119
131
  const userPath = hostAliasesUserPath(h);
120
132
  return new Error(
121
133
  `cannot resolve {host-short} for host ${JSON.stringify(host)}: no alias configured.\n` +
122
- `Add an entry to one of:\n` +
123
- ` ${HOST_ALIASES_SYSTEM_PATH} (system, requires sudo)\n` +
124
- ` ${userPath} (user-level, takes precedence on conflict)\n` +
134
+ `Add an entry to:\n` +
135
+ ` ${userPath}\n` +
125
136
  `Example:\n` +
126
137
  ` { "github.com": "gh", "gitlab.com": "gl" }`,
127
138
  );
@@ -200,12 +200,149 @@ export function encodeResponse(resp: Response): Buffer {
200
200
  * The line may or may not include the terminating '\n'; both are accepted
201
201
  * (matches Go's bufio.ReadBytes which keeps the delimiter).
202
202
  *
203
- * Throws on malformed JSON.
203
+ * Throws on malformed JSON or on payloads that fail boundary validation
204
+ * (see `validateRequest` for the rules). Wire input is untrusted —
205
+ * anything that knows the socket path can submit JSON, and the dispatcher
206
+ * acts on it (re-exec, file writes, clipboard). Validate before handing
207
+ * to the dispatcher rather than scattering checks across handlers.
204
208
  */
205
209
  export function decodeRequest(line: string | Buffer): Request {
206
210
  const text = typeof line === 'string' ? line : line.toString('utf8');
207
211
  const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
208
- return JSON.parse(trimmed) as Request;
212
+ const raw: unknown = JSON.parse(trimmed);
213
+ return validateRequest(raw);
214
+ }
215
+
216
+ // ── Request validation ─────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Maximum byte length for each string field on a Request. Values are sized
220
+ * to the field's job:
221
+ * - overrides (model, effort, permission_mode, agent): short identifiers
222
+ * - allowed_tools: comma-joined tool names, room for ~30 longest
223
+ * - session_id: UUID-shaped, but we don't pre-validate the shape here
224
+ * (that lives in the handler so the error message can mention UUID)
225
+ * - destination / name: filesystem paths / kebab-case names
226
+ * - text (clipboard): bounded for typical clipboard payloads
227
+ * - summary: continuity blob — generous but bounded; a malicious peer
228
+ * submitting hundreds of MB would otherwise wedge the writeFile path
229
+ */
230
+ const STRING_LIMITS: Record<string, number> = {
231
+ // Shared overrides
232
+ model: 64,
233
+ effort: 64,
234
+ permission_mode: 64,
235
+ allowed_tools: 4096,
236
+ agent: 256,
237
+ // Restart / switch / spawn
238
+ session_id: 128,
239
+ destination: 4096,
240
+ name: 256,
241
+ summary: 1024 * 1024, // 1 MiB
242
+ // Copy
243
+ text: 1024 * 1024, // 1 MiB
244
+ };
245
+
246
+ /** Discriminated union of fields legal for each Op. */
247
+ const STRING_FIELDS_BY_OP: Record<Op, readonly string[]> = {
248
+ restart: ['session_id', 'model', 'effort', 'permission_mode', 'allowed_tools', 'agent'],
249
+ switch: [
250
+ 'destination',
251
+ 'name',
252
+ 'summary',
253
+ 'session_id',
254
+ 'model',
255
+ 'effort',
256
+ 'permission_mode',
257
+ 'allowed_tools',
258
+ 'agent',
259
+ ],
260
+ spawn: [
261
+ 'destination',
262
+ 'name',
263
+ 'summary',
264
+ 'model',
265
+ 'effort',
266
+ 'permission_mode',
267
+ 'allowed_tools',
268
+ 'agent',
269
+ ],
270
+ copy_to_clipboard: ['text'],
271
+ };
272
+
273
+ const BOOL_FIELDS: readonly string[] = ['brief', 'chrome', 'ide', 'verbose', 'confirmed'];
274
+
275
+ /** Field names that are filesystem paths and must reject ".." traversal. */
276
+ const PATH_FIELDS: readonly string[] = ['destination'];
277
+
278
+ const KNOWN_OPS: readonly Op[] = ['restart', 'switch', 'spawn', 'copy_to_clipboard'];
279
+
280
+ /**
281
+ * Validate a parsed JSON value as a Request. Returns the validated value
282
+ * (typed as Request) on success; throws Error with a human-readable
283
+ * reason on failure. The caller (`decodeRequest`) is the only intended
284
+ * entry point — handlers consume the typed Request and trust its shape.
285
+ *
286
+ * Checks performed:
287
+ * 1. Value is a plain object (not array, null, or scalar).
288
+ * 2. `op` is one of the four known string discriminants.
289
+ * 3. Every string field on the per-op allowlist is either absent or a
290
+ * string under its byte-length cap, with no null bytes.
291
+ * 4. Path-shaped fields (`destination`) additionally reject `..`
292
+ * segments (delimited by `/` or string ends).
293
+ * 5. Boolean override fields are either absent, true, false, or null
294
+ * (null is treated as "preserve existing" by handlers).
295
+ *
296
+ * Unknown extra keys are tolerated (forward compatibility with newer
297
+ * clients) but their values are not validated.
298
+ */
299
+ export function validateRequest(value: unknown): Request {
300
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
301
+ throw new Error('request must be a JSON object');
302
+ }
303
+ const obj = value as Record<string, unknown>;
304
+ const op = obj.op;
305
+ if (typeof op !== 'string' || !KNOWN_OPS.includes(op as Op)) {
306
+ throw new Error(`unknown op ${JSON.stringify(op)}`);
307
+ }
308
+ const allowed = STRING_FIELDS_BY_OP[op as Op];
309
+ for (const field of allowed) {
310
+ const v = obj[field];
311
+ if (v === undefined) continue;
312
+ if (typeof v !== 'string') {
313
+ throw new Error(`field ${field} must be a string`);
314
+ }
315
+ if (v.includes('\x00')) {
316
+ throw new Error(`field ${field} contains a null byte`);
317
+ }
318
+ const cap = STRING_LIMITS[field] ?? 1024;
319
+ if (Buffer.byteLength(v, 'utf8') > cap) {
320
+ throw new Error(`field ${field} exceeds max length ${cap}`);
321
+ }
322
+ if (PATH_FIELDS.includes(field) && hasParentSegment(v)) {
323
+ throw new Error(`field ${field} contains a path-traversal segment ("..")`);
324
+ }
325
+ }
326
+ for (const field of BOOL_FIELDS) {
327
+ const v = obj[field];
328
+ if (v === undefined || v === null) continue;
329
+ if (typeof v !== 'boolean') {
330
+ throw new Error(`field ${field} must be a boolean`);
331
+ }
332
+ }
333
+ return obj as unknown as Request;
334
+ }
335
+
336
+ /**
337
+ * Returns true when the path contains a literal `..` segment — i.e. `..`
338
+ * delimited by `/` (or by start/end of the string). Catches `../etc`,
339
+ * `/foo/../bar`, `foo/..`, and bare `..`; ignores `foo..bar` and any
340
+ * other substring where `..` is part of a larger name.
341
+ */
342
+ function hasParentSegment(p: string): boolean {
343
+ // Split on '/' is sufficient — POSIX path semantics, and a Windows
344
+ // backslash path would never be a legitimate destination on the wire.
345
+ return p.split('/').includes('..');
209
346
  }
210
347
 
211
348
  /** Decode one newline-terminated JSON line into a Response. */
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { createServer, type Server, type Socket } from 'node:net';
17
- import { writeFile, unlink } from 'node:fs/promises';
17
+ import { writeFile, unlink, chmod } from 'node:fs/promises';
18
18
  import type { Config } from '../config.js';
19
19
  import { handoffContentPath, type HandoffSpec } from '../handoff.js';
20
20
  import {
@@ -144,6 +144,15 @@ export class SocketListener {
144
144
  * Open the AF_UNIX listener at spec.socketPath and start the accept
145
145
  * loop. Best-effort removes any stale socket file from a prior crashed
146
146
  * invocation at this path (net listen errors with EADDRINUSE otherwise).
147
+ *
148
+ * The socket file is chmod'd to 0600 immediately after bind so other
149
+ * UIDs on the host cannot dial it. Node's createServer() does NOT honor
150
+ * a mode option for AF_UNIX paths — it inherits the process umask, which
151
+ * defaults to 022 (world-readable) or worse depending on caller. We
152
+ * tighten unconditionally rather than rely on umask discipline at every
153
+ * launch site. The race window between bind and chmod is small (single
154
+ * tick) but real; we accept it as the trade vs. a per-process umask
155
+ * dance that would still leak any *other* file created in the same tick.
147
156
  */
148
157
  static async start(opts: StartOptions): Promise<SocketListener> {
149
158
  try {
@@ -169,6 +178,23 @@ export class SocketListener {
169
178
  server.once('listening', onOk);
170
179
  server.listen(opts.spec.socketPath);
171
180
  });
181
+ // Tighten the socket to owner-only rw — see method-level note above.
182
+ // Windows AF_UNIX implementations don't honor POSIX modes; the chmod
183
+ // call is a no-op there but harmless.
184
+ try {
185
+ await chmod(opts.spec.socketPath, 0o600);
186
+ } catch (err) {
187
+ // Don't leave a world-readable socket up if we can't tighten it.
188
+ await new Promise<void>((resolve) => server.close(() => resolve()));
189
+ try {
190
+ await unlink(opts.spec.socketPath);
191
+ } catch {
192
+ // already gone
193
+ }
194
+ throw new Error(
195
+ `failed to chmod socket to 0600 at ${opts.spec.socketPath}: ${(err as Error).message}`,
196
+ );
197
+ }
172
198
  return listener;
173
199
  }
174
200
 
@@ -305,7 +331,7 @@ export class SocketListener {
305
331
  !flagPresent(withOverrides, '--permission-mode')
306
332
  ) {
307
333
  const live = readLivePermissionMode(this.launchCWD, sid);
308
- if (live !== '') {
334
+ if (live !== undefined) {
309
335
  withOverrides = [...withOverrides, '--permission-mode', live];
310
336
  }
311
337
  }
@@ -351,7 +377,7 @@ export class SocketListener {
351
377
  sid !== ''
352
378
  ) {
353
379
  const live = readLivePermissionMode(this.launchCWD, sid);
354
- if (live !== '') {
380
+ if (live !== undefined) {
355
381
  withOverrides = [...withOverrides, '--permission-mode', live];
356
382
  }
357
383
  }
package/src/prompts.ts CHANGED
@@ -16,6 +16,7 @@ import { statSync } from 'node:fs';
16
16
  import { readFile, stat } from 'node:fs/promises';
17
17
  import { dirname, join } from 'node:path';
18
18
  import process from 'node:process';
19
+ import { fileURLToPath } from 'node:url';
19
20
  import { resolveSelfPath } from './paths.js';
20
21
 
21
22
  export interface PromptSet {
@@ -55,6 +56,16 @@ export interface LoadPromptsResult {
55
56
  * contains the shipped prompts/. This is the production path for any
56
57
  * `npm i -g @fnclaude/cli` install.
57
58
  * 4. `<exe-dir>/../share/fnclaude/prompts/` — FHS/AUR install layout.
59
+ * 5. `<module-dir>/../prompts/` — umbrella-install layout: when invoked
60
+ * via `npm i -g fnclaude` (the umbrella package), `process.argv[1]`
61
+ * points at the umbrella's `bin/fnc.js`, which `await import`s into
62
+ * `@fnclaude/cli/bin/fnc.js`. Candidates 2–4 all anchor at the
63
+ * umbrella's exe-dir and miss the cli package entirely. Anchoring at
64
+ * this module's own location reliably reaches the cli package root,
65
+ * since `prompts.ts` always lives inside `@fnclaude/cli` regardless
66
+ * of which bin invoked it. Works for both the `dist/prompts.js`
67
+ * (installed) and `src/prompts.ts` (dev) layouts — both sit one
68
+ * level under the package root where `prompts/` ships.
58
69
  *
59
70
  * Symlinks in the exe path are resolved before the search.
60
71
  *
@@ -110,10 +121,17 @@ export function findPromptsDir(): FindPromptsDirResult {
110
121
  // resolveSelfPath handles the argv[1] / execPath / realpathSync logic.
111
122
  const exeDir = dirname(resolveSelfPath());
112
123
 
124
+ // Anchor at this module's own location to catch the umbrella-install
125
+ // layout where argv[1] points at the fnclaude umbrella's bin/fnc.js
126
+ // (which delegates into @fnclaude/cli via dynamic import). The exe-dir
127
+ // candidates miss the cli package in that shape; this one doesn't.
128
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
129
+
113
130
  const candidates = [
114
131
  join(exeDir, 'prompts'),
115
132
  join(exeDir, '..', 'prompts'),
116
133
  join(exeDir, '..', 'share', 'fnclaude', 'prompts'),
134
+ join(moduleDir, '..', 'prompts'),
117
135
  ];
118
136
  for (const c of candidates) {
119
137
  try {
package/src/pty.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { mkdir, rmdir, stat } from 'node:fs/promises';
14
14
  import type { Stats } from 'node:fs';
15
- import { dirname } from 'node:path';
15
+ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
16
16
  import type { Config } from './config.js';
17
17
  import type { HandoffSpec } from './handoff.js';
18
18
  import { isFlag, isMagicWord, preserveArgs, splitLeadingMagic } from './args/preserve.js';
@@ -119,8 +119,18 @@ export interface CrossCwdMatch {
119
119
 
120
120
  /**
121
121
  * Scan `tail` for the cross-cwd redirect message. Returns null when no
122
- * match is found. When multiple matches appear (unlikely but defensive),
123
- * the LAST match wins.
122
+ * match is found OR when the captured `dest` fails safety validation.
123
+ * When multiple matches appear (unlikely but defensive), the LAST match
124
+ * wins.
125
+ *
126
+ * Security note: the `dest` capture flows into `silentRelaunch` and
127
+ * becomes the cwd for the relaunched process. The PTY stream is not a
128
+ * trusted channel — a hostile MCP tool (or any subprocess that prints to
129
+ * claude's terminal) can emit a fake "To resume, run: cd /tmp/evil &&
130
+ * claude --resume <uuid>" line and steer the parent into relaunching in
131
+ * an attacker-controlled directory. We refuse to act on a dest unless
132
+ * it's an absolute path that survives canonicalisation unchanged and
133
+ * contains no null bytes / `..` segments.
124
134
  */
125
135
  export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
126
136
  // Decode as Latin-1 so every byte maps to a code unit; the regex matches
@@ -137,7 +147,32 @@ export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
137
147
  last = m;
138
148
  }
139
149
  if (last === null) return null;
140
- return { dest: last[1]!, uuid: last[2]! };
150
+ const dest = last[1]!;
151
+ if (!isSafeDest(dest)) return null;
152
+ return { dest, uuid: last[2]! };
153
+ }
154
+
155
+ /**
156
+ * Reject `dest` values that shouldn't be honored as relaunch cwds:
157
+ * - contains a null byte
158
+ * - is not an absolute path (a relative dest would resolve against
159
+ * whatever the current cwd happens to be — non-obvious to a user
160
+ * reading the relaunch and easy to abuse)
161
+ * - contains a `..` segment delimited by `/` (path traversal)
162
+ * - doesn't round-trip through `path.resolve` (catches `/foo/./bar`,
163
+ * trailing slashes, and any other non-canonical form a peer might
164
+ * cook up to slip past parent-segment detection)
165
+ *
166
+ * On Windows we'd also want backslash handling; the cross-cwd-resume
167
+ * flow is POSIX-only by design (the Windows PTY stub disables it) so
168
+ * this validator targets POSIX paths.
169
+ */
170
+ function isSafeDest(dest: string): boolean {
171
+ if (dest.includes('\x00')) return false;
172
+ if (!isAbsolute(dest)) return false;
173
+ if (dest.split('/').includes('..')) return false;
174
+ if (resolvePath(dest) !== dest) return false;
175
+ return true;
141
176
  }
142
177
 
143
178
  // ── reconstructArgv ────────────────────────────────────────────────────────
package/src/resolver.ts CHANGED
@@ -75,6 +75,11 @@ export interface ResolveDeps {
75
75
  ghCmd: (args: readonly string[]) => Promise<GhResult>;
76
76
  /** Shell out `gh repo clone <ownerRepo> <dest>`. */
77
77
  runClone: (ownerRepo: string, dest: string) => Promise<void>;
78
+ /**
79
+ * User-visible log line (e.g. "cloning X → Y"). Production writes to
80
+ * stderr; tests stub it to silence test output and assert on the call.
81
+ */
82
+ log: (message: string) => void;
78
83
  }
79
84
 
80
85
  // ── Production dependency wiring ───────────────────────────────────────────
@@ -111,6 +116,9 @@ export function productionDeps(): ResolveDeps {
111
116
  },
112
117
  ghCmd,
113
118
  runClone,
119
+ log: (msg: string) => {
120
+ process.stderr.write(msg.endsWith('\n') ? msg : `${msg}\n`);
121
+ },
114
122
  };
115
123
  }
116
124
 
@@ -494,7 +502,7 @@ async function cloneAndReturn(
494
502
  }
495
503
 
496
504
  // Clone. gh decides SSH vs HTTPS from its config.
497
- process.stderr.write(`fnclaude: cloning ${ref.owner}/${ref.name} → ${target}\n`);
505
+ deps.log(`fnclaude: cloning ${ref.owner}/${ref.name} → ${target}`);
498
506
  try {
499
507
  await deps.runClone(`${ref.owner}/${ref.name}`, target);
500
508
  } catch (e) {
@@ -58,7 +58,7 @@ export function sessionJSONLPath(launchCWD: string, sessionID: string): string {
58
58
  * scan with last-wins semantics is correct (and adequate at the file
59
59
  * sizes real sessions reach).
60
60
  *
61
- * Returns `""` if the file is missing, unreadable, or contains no
61
+ * Returns `undefined` if the file is missing, unreadable, or contains no
62
62
  * permission-mode records. Callers should fall back to startup-arg
63
63
  * preservation in that case.
64
64
  *
@@ -70,15 +70,15 @@ export function sessionJSONLPath(launchCWD: string, sessionID: string): string {
70
70
  export function readLivePermissionMode(
71
71
  launchCWD: string,
72
72
  sessionID: string,
73
- ): string {
73
+ ): string | undefined {
74
74
  const path = sessionJSONLPath(launchCWD, sessionID);
75
75
  let data: string;
76
76
  try {
77
77
  data = readFileSync(path, 'utf8');
78
78
  } catch {
79
- return '';
79
+ return undefined;
80
80
  }
81
- let latest = '';
81
+ let latest: string | undefined;
82
82
  for (const line of data.split('\n')) {
83
83
  if (line.length === 0) continue;
84
84
  let r: { type?: unknown; permissionMode?: unknown };
package/src/worktree.ts CHANGED
@@ -63,10 +63,10 @@ export interface WorktreeInfo {
63
63
  /** Absolute filesystem path of the worktree. */
64
64
  path: string;
65
65
  /**
66
- * Bare branch name (e.g. "feat-x" or "worktree-feat-x"); "" if the
66
+ * Bare branch name (e.g. "feat-x" or "worktree-feat-x"); undefined if the
67
67
  * worktree is detached.
68
68
  */
69
- branch: string;
69
+ branch: string | undefined;
70
70
  }
71
71
 
72
72
  /**
@@ -86,7 +86,7 @@ export function listWorktrees(dir: string, runner: GitRunner = defaultGitRunner)
86
86
  const result: WorktreeInfo[] = [];
87
87
  for (const block of out.split('\n\n')) {
88
88
  let path = '';
89
- let branch = '';
89
+ let branch: string | undefined;
90
90
  for (const line of block.split('\n')) {
91
91
  if (line.startsWith('worktree ')) {
92
92
  path = line.slice('worktree '.length).trim();
@@ -114,20 +114,21 @@ export function listWorktrees(dir: string, runner: GitRunner = defaultGitRunner)
114
114
  * 3. Basename of the path == query (last-resort fallback for worktrees
115
115
  * whose branch was renamed or whose creator skipped the convention)
116
116
  *
117
- * Returns null when no entry matches. Empty `query` short-circuits to null
118
- * so that detached worktrees (branch="") can't be matched by accident.
117
+ * Returns null when no entry matches. Undefined `query` short-circuits to
118
+ * null so that detached worktrees (branch=undefined) can't be matched by
119
+ * accident.
119
120
  */
120
121
  export function findWorktree(
121
122
  worktrees: readonly WorktreeInfo[],
122
- query: string,
123
+ query: string | undefined,
123
124
  ): WorktreeInfo | null {
124
- if (query === '') return null;
125
+ if (query === undefined) return null;
125
126
 
126
127
  for (const wt of worktrees) {
127
128
  if (wt.branch === query) return wt;
128
129
  }
129
130
  for (const wt of worktrees) {
130
- if (wt.branch !== '' && stripWorktreePrefix(wt.branch) === query) return wt;
131
+ if (wt.branch !== undefined && stripWorktreePrefix(wt.branch) === query) return wt;
131
132
  }
132
133
  for (const wt of worktrees) {
133
134
  if (basename(wt.path) === query) return wt;
@@ -153,7 +154,7 @@ function basename(p: string): string {
153
154
  * No input is mutated. The four cases:
154
155
  *
155
156
  * 1. worktreeSet=false → carry through with worktreeMatched=false.
156
- * 2. Bare -w (worktreeArg="") → append --worktree to passthrough,
157
+ * 2. Bare -w (worktreeArg=undefined) → append --worktree to passthrough,
157
158
  * worktreeMatched=false.
158
159
  * 3. Existing worktree matched → swap cwd to the worktree path, set
159
160
  * worktreeMatched=true, suppress --worktree.
@@ -173,7 +174,7 @@ export function applyWorktreeIntercept(
173
174
  }
174
175
 
175
176
  // Bare -w with no name: push --worktree back through unchanged.
176
- if (a.worktreeArg === '') {
177
+ if (a.worktreeArg === undefined) {
177
178
  return withIntercepted(a, {
178
179
  passthrough: [...a.passthrough, '--worktree'],
179
180
  worktreeMatched: false,