scalix 1.2.0 → 1.3.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/dist/commands.js CHANGED
@@ -62,6 +62,11 @@ export async function loadCustomCommands(cwd = process.cwd()) {
62
62
  }
63
63
  function expandFileReferences(text, cwd) {
64
64
  return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
65
+ // Reject path traversal so a command file can't pull in files outside the
66
+ // project via `@../../etc/passwd`.
67
+ if (filePath.split(/[\\/]/).includes('..')) {
68
+ return `@${filePath} (path traversal blocked)`;
69
+ }
65
70
  const resolved = path.resolve(cwd, filePath);
66
71
  if (!existsSync(resolved))
67
72
  return `@${filePath} (file not found)`;
@@ -84,9 +89,18 @@ function expandInlineBash(text) {
84
89
  }
85
90
  });
86
91
  }
92
+ // Apply the same sanitization used for $ARGS to a single argument so positional
93
+ // args ($1..$9) can't smuggle shell metacharacters into a later `!`cmd`` inline
94
+ // bash expansion.
95
+ function sanitizeArg(arg) {
96
+ return arg
97
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
98
+ .replace(/\$/g, '\\$')
99
+ .replace(/`/g, '\\`');
100
+ }
87
101
  function expandPositionalArgs(text, args) {
88
102
  const parts = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
89
- const cleaned = parts.map(p => p.replace(/^["']|["']$/g, ''));
103
+ const cleaned = parts.map(p => sanitizeArg(p.replace(/^["']|["']$/g, '')));
90
104
  let result = text;
91
105
  for (let i = 0; i < 9; i++) {
92
106
  const placeholder = `$${i + 1}`;
@@ -100,12 +114,15 @@ export function expandCommand(command, args) {
100
114
  .replace(/\$/g, '\\$')
101
115
  .replace(/`/g, '\\`');
102
116
  let prompt = command.template;
117
+ // Run inline bash (!`cmd`) FIRST, against the raw template only, so that no
118
+ // user-supplied argument can be substituted into a command line before it is
119
+ // executed. Any $ / backtick coming from args afterward stays inert text.
120
+ prompt = expandInlineBash(prompt);
103
121
  prompt = expandPositionalArgs(prompt, args);
104
122
  prompt = prompt.replace(/\$ARGS/g, safeArgs);
105
123
  prompt = prompt.replace(/\$CWD/g, process.cwd());
106
124
  prompt = prompt.replace(/\$DATE/g, new Date().toISOString().split('T')[0]);
107
125
  prompt = expandFileReferences(prompt, process.cwd());
108
- prompt = expandInlineBash(prompt);
109
126
  return prompt;
110
127
  }
111
128
  export function commandsSummary(commands) {
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = '1.1.0';
1
+ export const VERSION = '1.3.0';
2
2
  export const PRODUCT_NAME = 'Scalix-Coder';
3
3
  export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
4
4
  export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
@@ -38,7 +38,7 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
38
38
 
39
39
  ## Scalix Platform
40
40
  When helping users build AI applications, agents, or LLM-powered features:
41
- - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
41
+ - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
42
42
  - Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
43
43
  - For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
44
44
  - For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
@@ -272,8 +272,11 @@ export const TOKEN_ESTIMATION = {
272
272
  export const PRIVATE_NETWORK_HOSTNAMES = new Set([
273
273
  'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
274
274
  '::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
275
+ '::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
276
+ // common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
277
+ 'metadata.google.internal', 'metadata',
275
278
  ]);
276
- export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal'];
279
+ export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
277
280
  export const PRIVATE_NETWORK_CIDRS = [
278
281
  /^10\./,
279
282
  /^172\.(1[6-9]|2\d|3[01])\./,
@@ -292,6 +295,78 @@ export function setNetworkSecurityOverrides(overrides) {
292
295
  allowedHosts: overrides.allowedHosts ? new Set(overrides.allowedHosts.map(h => h.toLowerCase())) : undefined,
293
296
  };
294
297
  }
298
+ /**
299
+ * Canonicalize a host string to a comparable IPv4 dotted-quad when it is a
300
+ * numeric address written in a non-standard form (decimal, hex, octal, or
301
+ * with fewer than 4 parts). Returns null when the input is not a bare IPv4-ish
302
+ * number. This closes SSRF bypasses like http://2130706433/ (=127.0.0.1),
303
+ * http://0x7f.1/ or http://127.1/.
304
+ */
305
+ export function canonicalizeIpv4(host) {
306
+ const h = host.trim().replace(/^\[|\]$/g, '');
307
+ if (!/^[0-9a-fx.]+$/i.test(h))
308
+ return null;
309
+ const parts = h.split('.');
310
+ if (parts.length === 0 || parts.length > 4)
311
+ return null;
312
+ const nums = [];
313
+ for (const p of parts) {
314
+ if (p === '')
315
+ return null;
316
+ let n;
317
+ if (/^0x[0-9a-f]+$/i.test(p))
318
+ n = parseInt(p, 16);
319
+ else if (/^0[0-7]+$/.test(p))
320
+ n = parseInt(p, 8);
321
+ else if (/^[0-9]+$/.test(p))
322
+ n = parseInt(p, 10);
323
+ else
324
+ return null;
325
+ if (!Number.isFinite(n) || n < 0)
326
+ return null;
327
+ nums.push(n);
328
+ }
329
+ // Fold the trailing part as a "rest" value per inet_aton semantics.
330
+ let value;
331
+ if (nums.length === 1) {
332
+ value = nums[0];
333
+ }
334
+ else {
335
+ const last = nums[nums.length - 1];
336
+ const head = nums.slice(0, -1);
337
+ if (head.some(n => n > 255))
338
+ return null;
339
+ const restMax = Math.pow(256, 4 - head.length);
340
+ if (last >= restMax)
341
+ return null;
342
+ value = last;
343
+ for (let i = 0; i < head.length; i++) {
344
+ value += head[i] * Math.pow(256, 3 - i);
345
+ }
346
+ }
347
+ if (value < 0 || value > 0xffffffff)
348
+ return null;
349
+ return [
350
+ (value >>> 24) & 0xff,
351
+ (value >>> 16) & 0xff,
352
+ (value >>> 8) & 0xff,
353
+ value & 0xff,
354
+ ].join('.');
355
+ }
356
+ /**
357
+ * Check whether a concrete IP address (already resolved, or a literal) points
358
+ * at a private/loopback/link-local/metadata range. Use this after DNS
359
+ * resolution to defeat DNS-rebinding and numeric-encoding SSRF.
360
+ */
361
+ export function isPrivateIp(ip) {
362
+ const canon = canonicalizeIpv4(ip);
363
+ const candidate = (canon ?? ip).toLowerCase();
364
+ if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
365
+ return true;
366
+ if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
367
+ return true;
368
+ return false;
369
+ }
295
370
  export function isPrivateHost(hostname) {
296
371
  const lower = hostname.toLowerCase();
297
372
  if (_networkOverrides.allowAll)
@@ -300,6 +375,12 @@ export function isPrivateHost(hostname) {
300
375
  return false;
301
376
  if (_networkOverrides.blockedHosts?.has(lower))
302
377
  return true;
378
+ // Normalize numeric IPv4 encodings (decimal/hex/octal/short forms) so that
379
+ // e.g. 2130706433, 0x7f000001 and 127.1 are all recognized as loopback.
380
+ const canonical = canonicalizeIpv4(lower);
381
+ if (canonical && (PRIVATE_NETWORK_HOSTNAMES.has(canonical) || PRIVATE_NETWORK_CIDRS.some(re => re.test(canonical)))) {
382
+ return true;
383
+ }
303
384
  if (PRIVATE_NETWORK_HOSTNAMES.has(lower))
304
385
  return true;
305
386
  if (PRIVATE_NETWORK_SUFFIXES.some(s => lower.endsWith(s)))
@@ -70,7 +70,7 @@ function scoreFiles(analysis, index) {
70
70
  deps.add(imp.to);
71
71
  importGraph.set(imp.from, deps);
72
72
  }
73
- const recentFiles = new Set(index.files
73
+ const recentFiles = new Set([...index.files]
74
74
  .sort((a, b) => b.modifiedAt - a.modifiedAt)
75
75
  .slice(0, 20)
76
76
  .map((f) => f.relativePath));
@@ -1,6 +1,7 @@
1
1
  export class CostTracker {
2
2
  entries = [];
3
3
  sessionStart = Date.now();
4
+ restoredRequestCount = 0;
4
5
  record(model, usage) {
5
6
  this.entries.push({
6
7
  model,
@@ -24,7 +25,10 @@ export class CostTracker {
24
25
  return this.entries.reduce((s, e) => s + e.completionTokens, 0);
25
26
  }
26
27
  get requestCount() {
27
- return this.entries.length;
28
+ // The single restored aggregate entry stands in for the previous session's
29
+ // request count, tracked separately so we don't over-count live requests.
30
+ const liveEntries = this.entries.filter((e) => e.model !== '(previous session)').length;
31
+ return liveEntries + this.restoredRequestCount;
28
32
  }
29
33
  get sessionDuration() {
30
34
  const s = Math.floor((Date.now() - this.sessionStart) / 1000);
@@ -57,25 +61,26 @@ export class CostTracker {
57
61
  }
58
62
  reset() {
59
63
  this.entries = [];
64
+ this.restoredRequestCount = 0;
60
65
  this.sessionStart = Date.now();
61
66
  }
62
67
  restore(state) {
63
68
  this.entries = [];
64
- if (state.requestCount > 0 && state.totalTokens >= 0) {
65
- const count = Math.max(1, state.requestCount);
66
- const perReqTokens = Math.floor(state.totalTokens / count);
67
- const remainder = state.totalTokens - perReqTokens * count;
68
- const perReqCost = state.totalCost / count;
69
- for (let i = 0; i < count; i++) {
70
- this.entries.push({
71
- model: '(previous session)',
72
- promptTokens: 0,
73
- completionTokens: 0,
74
- totalTokens: perReqTokens + (i === 0 ? remainder : 0),
75
- cost: perReqCost,
76
- timestamp: Date.now(),
77
- });
78
- }
69
+ // Store a single aggregate entry rather than fabricating `requestCount`
70
+ // synthetic entries — a corrupted/huge count would otherwise allocate
71
+ // unboundedly. The original request count is preserved separately.
72
+ this.restoredRequestCount = Number.isFinite(state.requestCount) && state.requestCount > 0
73
+ ? Math.floor(state.requestCount)
74
+ : 0;
75
+ if (this.restoredRequestCount > 0 && state.totalTokens >= 0) {
76
+ this.entries.push({
77
+ model: '(previous session)',
78
+ promptTokens: 0,
79
+ completionTokens: 0,
80
+ totalTokens: state.totalTokens,
81
+ cost: state.totalCost,
82
+ timestamp: Date.now(),
83
+ });
79
84
  }
80
85
  }
81
86
  getState() {
@@ -18,13 +18,13 @@ async function getBranch(cwd) {
18
18
  return (await run('git branch --show-current', cwd)) || (await run('git rev-parse --short HEAD', cwd)) || 'unknown';
19
19
  }
20
20
  async function getRemoteBranch(cwd) {
21
- return run('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', cwd);
21
+ return run('git rev-parse --abbrev-ref @{upstream}', cwd);
22
22
  }
23
23
  async function getRemoteUrl(cwd) {
24
- return run('git remote get-url origin 2>/dev/null', cwd);
24
+ return run('git remote get-url origin', cwd);
25
25
  }
26
26
  async function getAheadBehind(cwd) {
27
- const result = await run('git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null', cwd);
27
+ const result = await run('git rev-list --left-right --count @{upstream}...HEAD', cwd);
28
28
  if (!result)
29
29
  return { ahead: 0, behind: 0 };
30
30
  const parts = result.split('\t');
@@ -33,7 +33,7 @@ async function getAheadBehind(cwd) {
33
33
  return { ahead: Number(parts[1]) || 0, behind: Number(parts[0]) || 0 };
34
34
  }
35
35
  async function getStashCount(cwd) {
36
- const result = await run('git stash list 2>/dev/null', cwd);
36
+ const result = await run('git stash list', cwd);
37
37
  return result ? result.split('\n').filter(Boolean).length : 0;
38
38
  }
39
39
  async function getStatus(cwd) {
@@ -74,7 +74,10 @@ async function getStatus(cwd) {
74
74
  async function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
75
75
  const SEP = '<%>';
76
76
  const format = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`;
77
- const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count} 2>/dev/null`, cwd);
77
+ // No shell redirects here: `2>/dev/null` breaks under cmd.exe on Windows
78
+ // (redirects to a literal \dev\null path and fails the whole command).
79
+ // run() already discards stderr and swallows failures.
80
+ const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count}`, cwd);
78
81
  if (!result)
79
82
  return [];
80
83
  const commits = [];
@@ -20,7 +20,14 @@ function surroundingContext(text, pos, radius = 60) {
20
20
  const end = Math.min(text.length, pos + radius);
21
21
  return text.slice(start, end).replace(/\n/g, ' ').trim();
22
22
  }
23
- export function extractCodeClaims(text) {
23
+ // Blank out fenced code blocks (```...```) with equal-length whitespace so the
24
+ // model's own newly-authored symbols aren't flagged as ungrounded, while
25
+ // keeping character offsets stable for surroundingContext().
26
+ function maskFencedCodeBlocks(text) {
27
+ return text.replace(/```[\s\S]*?(?:```|$)/g, (block) => block.replace(/[^\n]/g, ' '));
28
+ }
29
+ export function extractCodeClaims(rawText) {
30
+ const text = maskFencedCodeBlocks(rawText);
24
31
  const seen = new Set();
25
32
  const claims = [];
26
33
  function add(type, value, position) {
package/dist/headless.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import readline from 'node:readline';
2
2
  import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
3
  import { execSync } from 'node:child_process';
5
4
  import { createAgent } from './agent.js';
6
5
  import { defaultTools } from './tools.js';
@@ -23,6 +22,7 @@ import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote
23
22
  import { buildProjectIndex } from './project-index.js';
24
23
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
25
24
  import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
25
+ import { isWorkspaceTrusted } from './workspace-trust.js';
26
26
  import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
27
27
  import { hooksSummary } from './hooks.js';
28
28
  import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
@@ -131,7 +131,7 @@ async function buildEnhancedInstructions() {
131
131
  const ctx = await buildProjectContext(cwd);
132
132
  return contextToInstructions(base, ctx);
133
133
  }
134
- async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
134
+ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
135
135
  const apiKey = process.env[ENV_API_KEY];
136
136
  if (!apiKey) {
137
137
  console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
@@ -159,7 +159,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
159
159
  process.exit(1);
160
160
  }
161
161
  const mcpTools = loadMcpServices();
162
- const plugins = await discoverPlugins(process.cwd());
162
+ // Only discover/load project-local plugins in a trusted workspace — plugins
163
+ // dynamically import and execute code from the project directory.
164
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
165
+ if (!isWorkspaceTrusted()) {
166
+ process.stderr.write('Project plugins skipped (untrusted workspace).\n');
167
+ }
163
168
  const pluginTools = getPluginTools(plugins);
164
169
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
165
170
  const instructions = await buildEnhancedInstructions();
@@ -202,16 +207,22 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
202
207
  contextRetrieval: true,
203
208
  });
204
209
  let fullResponse = '';
205
- agent.on('stream:end', (text) => { fullResponse = text; });
206
210
  agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
207
211
  agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
208
212
  agent.on('verify:fail', (r) => process.stderr.write(`\x1b[31m✗ ${r.checker} failed: ${r.output.split('\n')[0]}\x1b[0m\n`));
209
213
  agent.on('error', (err) => process.stderr.write(`\x1b[31mError: ${err.message}\x1b[0m\n`));
214
+ agent.on('usage', (data) => {
215
+ costTracker.record(data.model || selectedModel[0], data);
216
+ });
210
217
  try {
211
- await agent.send(prompt);
218
+ // Use the return value (applies JSON extraction for --format json/json-schema)
219
+ // rather than the raw stream text.
220
+ fullResponse = await agent.send(prompt);
212
221
  }
213
222
  catch (err) {
214
223
  console.error(`Error: ${err.message}`);
224
+ if (beforeExit)
225
+ await beforeExit();
215
226
  process.exit(1);
216
227
  }
217
228
  let formatted;
@@ -245,9 +256,11 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
245
256
  process.stdout.write(formatted + '\n');
246
257
  }
247
258
  await closeBrowser();
259
+ if (beforeExit)
260
+ await beforeExit();
248
261
  process.exit(0);
249
262
  }
250
- async function runPipeMode() {
263
+ async function runPipeMode(beforeExit) {
251
264
  const MAX_PIPE_BYTES = 50 * 1024 * 1024;
252
265
  const chunks = [];
253
266
  let totalBytes = 0;
@@ -257,6 +270,8 @@ async function runPipeMode() {
257
270
  totalBytes += Buffer.byteLength(str);
258
271
  if (totalBytes > MAX_PIPE_BYTES) {
259
272
  console.error(`Error: Pipe input exceeds ${MAX_PIPE_BYTES / 1024 / 1024}MB limit.`);
273
+ if (beforeExit)
274
+ await beforeExit();
260
275
  process.exit(1);
261
276
  }
262
277
  chunks.push(str);
@@ -264,14 +279,18 @@ async function runPipeMode() {
264
279
  }
265
280
  catch (err) {
266
281
  console.error(`Error reading pipe input: ${err.message}`);
282
+ if (beforeExit)
283
+ await beforeExit();
267
284
  process.exit(1);
268
285
  }
269
286
  const prompt = chunks.join('').trim();
270
287
  if (!prompt) {
271
288
  console.error('Error: No input received from stdin.');
289
+ if (beforeExit)
290
+ await beforeExit();
272
291
  process.exit(1);
273
292
  }
274
- await runSinglePrompt(prompt);
293
+ await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
275
294
  }
276
295
  async function runInteractive() {
277
296
  const apiKey = process.env[ENV_API_KEY];
@@ -292,7 +311,12 @@ async function runInteractive() {
292
311
  throw new Error('No models configured. Set SCALIX_MODEL env var or run interactive setup.');
293
312
  }
294
313
  const mcpTools = loadMcpServices();
295
- const plugins = await discoverPlugins(process.cwd());
314
+ // Only discover/load project-local plugins in a trusted workspace — plugins
315
+ // dynamically import and execute code from the project directory.
316
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
317
+ if (!isWorkspaceTrusted()) {
318
+ process.stderr.write('Project plugins skipped (untrusted workspace).\n');
319
+ }
296
320
  const pluginTools = getPluginTools(plugins);
297
321
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
298
322
  const instructions = await buildEnhancedInstructions();
@@ -374,20 +398,27 @@ async function runInteractive() {
374
398
  console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
375
399
  }
376
400
  console.log('');
377
- function shutdown() {
401
+ let shuttingDown = false;
402
+ async function shutdown() {
403
+ if (shuttingDown)
404
+ return;
405
+ shuttingDown = true;
378
406
  const msgs = agent.getMessages();
379
407
  if (msgs.length > 0) {
380
- saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
408
+ // Await the save so sessions aren't lost on SIGINT (process.exit would
409
+ // otherwise cut off the fire-and-forget write).
410
+ await saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
381
411
  }
382
412
  if (costTracker.totalTokens > 0) {
383
413
  console.log(`\n\x1b[36mSession: ${costTracker.totalTokens.toLocaleString()} tokens${costTracker.totalCost > 0 ? ` ($${costTracker.totalCost.toFixed(4)})` : ''}\x1b[0m`);
384
414
  }
385
415
  console.log('Goodbye!');
386
416
  rl.close();
387
- closeBrowser().finally(() => process.exit(0));
417
+ await closeBrowser().catch(() => { });
418
+ process.exit(0);
388
419
  }
389
- process.on('SIGINT', shutdown);
390
- process.on('SIGTERM', shutdown);
420
+ process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
421
+ process.on('SIGTERM', () => { shutdown().catch(() => process.exit(1)); });
391
422
  const handleCommand = async (input) => {
392
423
  const cmd = input.split(' ')[0].toLowerCase();
393
424
  if (cmd === '/help') {
@@ -656,7 +687,7 @@ async function runInteractive() {
656
687
  return true;
657
688
  }
658
689
  if (cmd === '/quit' || cmd === '/exit') {
659
- shutdown();
690
+ await shutdown();
660
691
  return true;
661
692
  }
662
693
  const customCmd = customCmds.find((c) => cmd === `/${c.name}`);
@@ -730,11 +761,15 @@ async function main() {
730
761
  }
731
762
  // Set up worktree isolation if requested
732
763
  let worktreeInfo = null;
764
+ // Capture the original cwd BEFORE any process.chdir so cleanup can restore it
765
+ // and resolve the worktree parent correctly.
766
+ const originalCwd = process.cwd();
767
+ let cleanup = async () => { };
733
768
  if (worktree) {
734
769
  const { createWorktree, removeWorktree } = await import('./worktree.js');
735
770
  const name = worktreeName || `session-${Date.now().toString(36)}`;
736
771
  try {
737
- worktreeInfo = await createWorktree(name, process.cwd());
772
+ worktreeInfo = await createWorktree(name, originalCwd);
738
773
  process.chdir(worktreeInfo.path);
739
774
  setSandboxRoot(worktreeInfo.path);
740
775
  console.log(`Working in isolated worktree: ${worktreeInfo.branch} → ${worktreeInfo.path}`);
@@ -743,38 +778,46 @@ async function main() {
743
778
  console.error(`Failed to create worktree: ${err.message}`);
744
779
  process.exit(1);
745
780
  }
746
- const cleanup = async () => {
747
- if (worktreeInfo) {
748
- try {
749
- const origCwd = process.env.SCALIX_ORIGINAL_CWD || process.cwd();
750
- process.chdir(path.dirname(worktreeInfo.path));
751
- await removeWorktree(worktreeInfo, path.dirname(path.dirname(worktreeInfo.path)));
752
- }
753
- catch { }
781
+ let cleanedUp = false;
782
+ cleanup = async () => {
783
+ if (cleanedUp || !worktreeInfo)
784
+ return;
785
+ cleanedUp = true;
786
+ try {
787
+ // Return to the original directory before removing the worktree so we
788
+ // are not standing inside the directory being deleted.
789
+ process.chdir(originalCwd);
790
+ await removeWorktree(worktreeInfo, originalCwd);
754
791
  }
792
+ catch { }
755
793
  };
756
- process.on('exit', () => { cleanup().catch(() => { }); });
794
+ // 'exit' handlers cannot run async work — SIGINT awaits cleanup explicitly.
757
795
  process.on('SIGINT', () => { cleanup().then(() => process.exit(0)).catch(() => process.exit(1)); });
758
- process.env.SCALIX_ORIGINAL_CWD = process.cwd();
759
796
  }
760
- switch (mode) {
761
- case 'run':
762
- if (!prompt) {
763
- console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
764
- process.exit(1);
765
- }
766
- await runSinglePrompt(prompt, output, format, outputSchema);
767
- break;
768
- case 'pipe':
769
- await runPipeMode();
770
- break;
771
- case 'ipc':
772
- await runIpcServer();
773
- break;
774
- case 'interactive':
775
- default:
776
- await runInteractive();
777
- break;
797
+ try {
798
+ switch (mode) {
799
+ case 'run':
800
+ if (!prompt) {
801
+ console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
802
+ await cleanup();
803
+ process.exit(1);
804
+ }
805
+ await runSinglePrompt(prompt, output, format, outputSchema, cleanup);
806
+ break;
807
+ case 'pipe':
808
+ await runPipeMode(cleanup);
809
+ break;
810
+ case 'ipc':
811
+ await runIpcServer();
812
+ break;
813
+ case 'interactive':
814
+ default:
815
+ await runInteractive();
816
+ break;
817
+ }
818
+ }
819
+ finally {
820
+ await cleanup();
778
821
  }
779
822
  }
780
823
  main().catch((err) => {
package/dist/hooks.js CHANGED
@@ -1,10 +1,42 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { resolve, join, basename } from 'node:path';
3
3
  import { exec } from 'node:child_process';
4
- import { promisify } from 'node:util';
5
4
  import { logger } from './logger.js';
6
5
  import { PROJECT_LOOKUP_DIRS, HOOK_DEFAULTS } from './constants.js';
7
- const execAsync = promisify(exec);
6
+ import { sanitizeEnvForCommand } from './sandbox.js';
7
+ // Hook commands are written in POSIX sh syntax ($VAR, pipes, etc). On Windows
8
+ // the default exec shell is cmd.exe, which silently mangles them — so resolve
9
+ // a real POSIX shell (Git Bash) when one is available. `null` = not resolved.
10
+ let cachedHookShell = null;
11
+ function isPosixShell(shellPath) {
12
+ const name = basename(shellPath).toLowerCase().replace(/\.exe$/, '');
13
+ return name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash';
14
+ }
15
+ function resolveHookShell() {
16
+ if (cachedHookShell !== null)
17
+ return cachedHookShell;
18
+ if (process.platform !== 'win32') {
19
+ cachedHookShell = undefined;
20
+ return undefined;
21
+ }
22
+ const candidates = [
23
+ process.env.SCALIX_HOOK_SHELL,
24
+ process.env.SHELL,
25
+ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
26
+ join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
27
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') : undefined,
28
+ ];
29
+ for (const c of candidates) {
30
+ // Skip WSL/System32 bash implicitly: only explicit paths are checked, and
31
+ // non-POSIX shells (e.g. a SHELL pointing at PowerShell) are rejected.
32
+ if (c && isPosixShell(c) && existsSync(c)) {
33
+ cachedHookShell = c;
34
+ return c;
35
+ }
36
+ }
37
+ cachedHookShell = undefined;
38
+ return undefined;
39
+ }
8
40
  const HOOK_PATHS = PROJECT_LOOKUP_DIRS.map(d => `${d}/hooks.json`);
9
41
  export function loadHooks(cwd = process.cwd()) {
10
42
  for (const relPath of HOOK_PATHS) {
@@ -112,17 +144,24 @@ function parseHookOutput(stdout, exitCode) {
112
144
  }
113
145
  export async function executeHook(hook, env, stdinData) {
114
146
  const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
115
- const hookEnv = { ...process.env, ...env };
147
+ // Strip secrets from the inherited environment so hook commands can't read
148
+ // API keys, tokens, cloud credentials, etc. The SCALIX_* context vars in
149
+ // `env` are still applied on top.
150
+ const hookEnv = { ...sanitizeEnvForCommand(process.env), ...env };
116
151
  const ac = new AbortController();
117
152
  const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
118
153
  try {
154
+ const shell = resolveHookShell();
119
155
  const child = exec(hook.command, {
120
156
  timeout,
121
157
  env: hookEnv,
122
158
  maxBuffer: HOOK_DEFAULTS.maxBuffer,
123
159
  signal: ac.signal,
160
+ ...(shell ? { shell } : {}),
124
161
  });
125
162
  if (stdinData && child.stdin) {
163
+ // The hook may exit without reading stdin; swallow the resulting EPIPE.
164
+ child.stdin.on('error', () => { });
126
165
  child.stdin.write(stdinData);
127
166
  child.stdin.end();
128
167
  }
@@ -132,8 +171,19 @@ export async function executeHook(hook, env, stdinData) {
132
171
  child.stdout?.on('data', (d) => { stdout += d; });
133
172
  child.stderr?.on('data', (d) => { stderr += d; });
134
173
  child.on('error', reject);
135
- child.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
174
+ child.on('close', (code, signal) => resolve({
175
+ stdout,
176
+ stderr,
177
+ code: code ?? 0,
178
+ // A killed child reports code=null + a signal (or child.killed on
179
+ // Windows). We only ever kill on timeout/abort, so treat it as such —
180
+ // otherwise `code ?? 0` would misreport the hook as allowed.
181
+ killed: child.killed || signal !== null,
182
+ }));
136
183
  });
184
+ if (result.killed) {
185
+ return { allowed: false, error: `Hook timed out after ${timeout}ms` };
186
+ }
137
187
  return parseHookOutput(result.stdout, result.code);
138
188
  }
139
189
  catch (err) {