scalix 1.5.2 → 1.6.0

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.
@@ -0,0 +1,117 @@
1
+ // Connection-pinned fetch.
2
+ //
3
+ // The SSRF checks in tools-web.ts and mcp.ts resolve the hostname, decide the
4
+ // address is public, and then let fetch() resolve the name AGAIN on its own.
5
+ // Between the two lookups an attacker who controls DNS can flip the answer
6
+ // (DNS rebinding): the validation sees a public address, the connection lands
7
+ // on 127.0.0.1 / 169.254.169.254 / anything internal, with whatever
8
+ // Authorization header the caller attached. Validating and connecting are two
9
+ // different resolutions of the same name, so the check protects nothing.
10
+ //
11
+ // The fix is to make them the SAME resolution: resolve once, keep the
12
+ // addresses, and hand them to the connection as a pinned `lookup` so the
13
+ // socket can only ever reach an address that was actually validated.
14
+ //
15
+ // node:http/https are used rather than global fetch for exactly one reason —
16
+ // fetch (undici) exposes no way to pin DNS. The hostname is left untouched, so
17
+ // SNI, the Host header and the certificate's hostname check all still apply to
18
+ // the real name; only the address it dials is constrained.
19
+ import dns from 'node:dns';
20
+ import http from 'node:http';
21
+ import https from 'node:https';
22
+ import zlib from 'node:zlib';
23
+ import { Readable } from 'node:stream';
24
+ /**
25
+ * Resolve a hostname for pinning. Returns [] when the name cannot be resolved
26
+ * (or the caller deliberately skipped pinning) — the request then falls back
27
+ * to ordinary fetch, whose own failure is the honest outcome.
28
+ */
29
+ export async function resolveLookupAddresses(hostname) {
30
+ try {
31
+ const addresses = await dns.promises.lookup(hostname, { all: true });
32
+ return addresses.length > 0 ? addresses : [];
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ export function pinnedLookup(addresses) {
39
+ const list = addresses.map((a) => ({ address: a.address, family: a.family }));
40
+ return function lookup(_hostname, options, callback) {
41
+ if (options && options.all) {
42
+ callback(null, list);
43
+ }
44
+ else {
45
+ const first = list[0];
46
+ callback(null, first.address, first.family);
47
+ }
48
+ };
49
+ }
50
+ /** Decode a compressed HTTP stream so callers see the same bytes fetch() would. */
51
+ function decodeBody(res) {
52
+ const encoding = res.headers['content-encoding'];
53
+ if (encoding === 'gzip')
54
+ return res.pipe(zlib.createGunzip());
55
+ if (encoding === 'deflate')
56
+ return res.pipe(zlib.createInflate());
57
+ if (encoding === 'br')
58
+ return res.pipe(zlib.createBrotliDecompress());
59
+ return res;
60
+ }
61
+ /**
62
+ * fetch() with the connection pinned to pre-validated addresses.
63
+ *
64
+ * Drop-in for the call sites in tools-web.ts and mcp.ts: same RequestInit
65
+ * shape, same Response contract (streaming body included, so long-lived SSE
66
+ * responses such as MCP's keep working).
67
+ */
68
+ export async function pinnedFetch(url, init, addresses) {
69
+ if (addresses.length === 0) {
70
+ // Nothing was validated to pin to; the caller decided this is allowed.
71
+ return fetch(url, init);
72
+ }
73
+ const transport = url.protocol === 'https:' ? https : http;
74
+ const headers = new Headers(init.headers);
75
+ const method = (init.method || 'GET').toUpperCase();
76
+ const body = init.body;
77
+ const nodeResponse = await new Promise((resolve, reject) => {
78
+ const request = transport.request(url, {
79
+ method,
80
+ // The whole point: dial only what we validated.
81
+ lookup: pinnedLookup(addresses),
82
+ // Fresh connection per request — a pooled socket is keyed by host:port
83
+ // and would carry no lookup of its own.
84
+ agent: false,
85
+ signal: init.signal ?? undefined,
86
+ headers: Object.fromEntries(headers.entries()),
87
+ }, resolve);
88
+ request.on('error', reject);
89
+ if (body !== undefined && body !== null) {
90
+ if (typeof body === 'string')
91
+ request.write(body);
92
+ else if (body instanceof Uint8Array)
93
+ request.write(body);
94
+ else if (body instanceof URLSearchParams)
95
+ request.write(body.toString());
96
+ else
97
+ reject(new Error(`pinnedFetch: unsupported request body type (${typeof body})`));
98
+ }
99
+ request.end();
100
+ });
101
+ const responseHeaders = new Headers();
102
+ for (const [key, value] of Object.entries(nodeResponse.headers)) {
103
+ if (value === undefined)
104
+ continue;
105
+ responseHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
106
+ }
107
+ // A decoded stream's length no longer matches the compressed original, and a
108
+ // stale content-length makes readers stop early.
109
+ const decoded = nodeResponse.headers['content-encoding'] !== undefined;
110
+ if (decoded)
111
+ responseHeaders.delete('content-length');
112
+ return new Response(Readable.toWeb(decodeBody(nodeResponse)), {
113
+ status: nodeResponse.statusCode ?? 502,
114
+ statusText: nodeResponse.statusMessage || '',
115
+ headers: responseHeaders,
116
+ });
117
+ }
package/dist/settings.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { setSecurityConfig, setScopedPermissionRules } from './sandbox.js';
4
+ import { COMMAND_SANDBOX_MODES } from './command-sandbox.js';
4
5
  import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, MULTI_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, setNetworkSecurityOverrides, normalizeModelId, } from './constants.js';
5
6
  import { getApiKeyFromKeychain, setApiKeyInKeychain, isKeychainAvailable } from './keychain.js';
6
7
  import { isProviderId } from './providers/index.js';
@@ -165,22 +166,41 @@ function validateConfig(parsed) {
165
166
  if (typeof parsed.disableUpdateCheck === 'boolean') {
166
167
  safe.disableUpdateCheck = parsed.disableUpdateCheck;
167
168
  }
169
+ // OS-level command sandbox (command-sandbox.ts). Opt-in: absent or invalid
170
+ // values resolve to 'off' so existing installs never change behaviour.
171
+ if (typeof parsed.commandSandbox === 'string' && COMMAND_SANDBOX_MODES.includes(parsed.commandSandbox)) {
172
+ safe.commandSandbox = parsed.commandSandbox;
173
+ }
168
174
  // BYOK (validateConfig drops unknown keys, so these MUST be whitelisted here
169
175
  // or they vanish on the next saveConfig — spec §4 migration gotcha #1).
170
176
  if (isProviderId(parsed.provider)) {
171
177
  safe.provider = parsed.provider;
172
178
  }
173
179
  if (parsed.providers && typeof parsed.providers === 'object' && !Array.isArray(parsed.providers)) {
174
- const po = parsed.providers.openai;
175
- if (po && typeof po === 'object' && !Array.isArray(po)) {
180
+ const registry = {};
181
+ for (const [key, raw] of Object.entries(parsed.providers)) {
182
+ if (!isProviderId(key))
183
+ continue;
184
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
185
+ continue;
186
+ const po = raw;
176
187
  const creds = {};
177
188
  if (typeof po.apiKey === 'string')
178
189
  creds.apiKey = po.apiKey;
179
190
  if (typeof po.baseUrl === 'string' && po.baseUrl.length > 0)
180
191
  creds.baseUrl = po.baseUrl;
192
+ if (typeof po.name === 'string' && po.name.trim())
193
+ creds.name = po.name.trim();
194
+ if (typeof po.enabled === 'boolean')
195
+ creds.enabled = po.enabled;
181
196
  if (Object.keys(creds).length > 0)
182
- safe.providers = { openai: creds };
197
+ registry[key] = creds;
183
198
  }
199
+ if (Object.keys(registry).length > 0)
200
+ safe.providers = registry;
201
+ }
202
+ if (isStringArray(parsed.disabledProviders)) {
203
+ safe.disabledProviders = parsed.disabledProviders.filter((p) => isProviderId(p));
184
204
  }
185
205
  return safe;
186
206
  }
@@ -286,6 +306,33 @@ function validateScopedPermissions(raw) {
286
306
  }
287
307
  return valid;
288
308
  }
309
+ /**
310
+ * Security fields a *project* file may set. Project-local settings ship inside
311
+ * the repo, so they are attacker-controllable; only fields that tighten the
312
+ * posture (more blocking, more read-only) may come from them. Anything that
313
+ * loosens the sandbox — reclassifying tools, disabling blocklist/injection
314
+ * patterns, allowing private hosts or env leakage — stays a user-level or
315
+ * managed-settings decision.
316
+ */
317
+ const PROJECT_SAFE_SECURITY_FIELDS = new Set([
318
+ 'additionalBlockedCommands',
319
+ 'additionalBlockedHosts',
320
+ 'additionalInjectionPatterns',
321
+ 'additionalReadOnlyTools',
322
+ 'additionalSensitiveEnvPatterns',
323
+ 'maxImageSize',
324
+ ]);
325
+ function filterProjectSafeSecurity(sec) {
326
+ if (!sec)
327
+ return undefined;
328
+ const safe = {};
329
+ for (const key of PROJECT_SAFE_SECURITY_FIELDS) {
330
+ const val = sec[key];
331
+ if (val !== undefined)
332
+ safe[key] = val;
333
+ }
334
+ return safe;
335
+ }
289
336
  async function loadLocalOverrides(config) {
290
337
  // Project-local settings are attacker-controllable (they ship in the repo), so
291
338
  // only honor security-relevant overrides in a trusted workspace, and always
@@ -305,19 +352,36 @@ async function loadLocalOverrides(config) {
305
352
  const validated = validateConfig(overrides);
306
353
  if (validated.models)
307
354
  config.models = validated.models;
308
- if (validated.permissionMode)
355
+ // A repo may only TIGHTEN the permission mode (plan). 'auto'/'yolo' from
356
+ // a project file would silently disable confirmation prompts after a
357
+ // single "Trust" click — those stay user/managed-level decisions.
358
+ if (validated.permissionMode === 'plan') {
309
359
  config.permissionMode = validated.permissionMode;
360
+ }
361
+ else if (validated.permissionMode === 'auto' || validated.permissionMode === 'yolo') {
362
+ process.stderr.write(`Project settings tried to set permissionMode "${validated.permissionMode}" — ignored (escalation from project files is not allowed; set it via \`scalix\` config or SCALIX_PERMISSION_MODE).\n`);
363
+ }
310
364
  if (validated.thinkingMode)
311
365
  config.thinkingMode = validated.thinkingMode;
312
366
  if (validated.limits)
313
367
  config.limits = { ...config.limits, ...validated.limits };
314
- if (validated.security)
315
- config.security = { ...config.security, ...validated.security };
368
+ const projectSecurity = filterProjectSafeSecurity(validated.security);
369
+ if (projectSecurity)
370
+ config.security = { ...config.security, ...projectSecurity };
316
371
  if (validated.customModels)
317
372
  config.customModels = { ...config.customModels, ...validated.customModels };
373
+ // Scoped permissions from a project file may only DENY. An 'allow' rule
374
+ // in the repo would pre-approve exactly the tools the repo author chose.
318
375
  const scoped = validateScopedPermissions(overrides.scopedPermissions);
319
- if (scoped)
320
- config.scopedPermissions = scoped;
376
+ if (scoped) {
377
+ const denyOnly = scoped.filter(r => r.action === 'deny');
378
+ if (denyOnly.length > 0) {
379
+ config.scopedPermissions = denyOnly;
380
+ }
381
+ else if (scoped.length > 0) {
382
+ process.stderr.write('Project settings tried to add scoped permission "allow" rules — ignored (project files may only add deny rules).\n');
383
+ }
384
+ }
321
385
  break;
322
386
  }
323
387
  catch { }
@@ -332,16 +396,21 @@ async function loadManagedSettings(config) {
332
396
  try {
333
397
  const raw = await fs.readFile(managedPath, 'utf-8');
334
398
  const managed = JSON.parse(raw);
335
- if (managed.permissionMode)
336
- config.permissionMode = managed.permissionMode;
337
- if (managed.security)
338
- config.security = { ...config.security, ...managed.security };
339
- if (managed.scopedPermissions)
399
+ // Managed settings are the highest-precedence trust anchor — validate
400
+ // them at least as strictly as user config so a malformed policy file
401
+ // can't corrupt permissionMode/security with unvalidated values.
402
+ const validated = validateConfig(managed);
403
+ if (validated.permissionMode)
404
+ config.permissionMode = validated.permissionMode;
405
+ if (validated.security)
406
+ config.security = { ...config.security, ...validated.security };
407
+ const scoped = validateScopedPermissions(managed.scopedPermissions);
408
+ if (scoped)
340
409
  config.scopedPermissions = [
341
410
  ...(config.scopedPermissions || []),
342
- ...managed.scopedPermissions,
411
+ ...scoped,
343
412
  ];
344
- if (managed.disableUpdateCheck !== undefined)
413
+ if (typeof managed.disableUpdateCheck === 'boolean')
345
414
  config.disableUpdateCheck = managed.disableUpdateCheck;
346
415
  break;
347
416
  }
@@ -361,11 +430,15 @@ export async function loadConfig() {
361
430
  if (keychainKey)
362
431
  config.apiKey = keychainKey;
363
432
  }
364
- // Per-provider keychain slot (BYOK): openai key lives under its own account.
365
- if (config.providers?.openai && !config.providers.openai.apiKey) {
366
- const k = await getApiKeyFromKeychain(OPENAI_KEYCHAIN_ACCOUNT).catch(() => null);
367
- if (k)
368
- config.providers.openai.apiKey = k;
433
+ // Per-provider keychain slots (BYOK): each provider key has its own account.
434
+ if (config.providers) {
435
+ for (const [key, creds] of Object.entries(config.providers)) {
436
+ if (creds.apiKey)
437
+ continue;
438
+ const k = await getApiKeyFromKeychain(providerKeychainAccount(key)).catch(() => null);
439
+ if (k)
440
+ creds.apiKey = k;
441
+ }
369
442
  }
370
443
  // Apply local overrides (project-level, gitignored)
371
444
  config = await loadLocalOverrides(config);
@@ -422,17 +495,19 @@ export async function saveConfig(config) {
422
495
  else
423
496
  plaintextSecret = true;
424
497
  }
425
- if (config.providers?.openai?.apiKey) {
426
- const stored = await setApiKeyInKeychain(config.providers.openai.apiKey, OPENAI_KEYCHAIN_ACCOUNT).catch(() => false);
427
- if (stored) {
428
- configToWrite.providers = {
429
- ...configToWrite.providers,
430
- openai: { ...configToWrite.providers.openai, apiKey: '' },
431
- };
432
- }
433
- else {
434
- plaintextSecret = true;
498
+ if (config.providers) {
499
+ const nextProviders = {};
500
+ for (const [key, creds] of Object.entries(config.providers)) {
501
+ nextProviders[key] = { ...creds };
502
+ if (creds.apiKey) {
503
+ const stored = await setApiKeyInKeychain(creds.apiKey, providerKeychainAccount(key)).catch(() => false);
504
+ if (stored)
505
+ nextProviders[key].apiKey = '';
506
+ else
507
+ plaintextSecret = true;
508
+ }
435
509
  }
510
+ configToWrite.providers = nextProviders;
436
511
  }
437
512
  }
438
513
  await fs.writeFile(getConfigFile(), JSON.stringify(configToWrite, null, 2), { encoding: 'utf-8', mode: 0o600 });
@@ -447,20 +522,85 @@ export function isConfigured(config) {
447
522
  }
448
523
  // ─── BYOK provider resolution ───
449
524
  export const OPENAI_KEYCHAIN_ACCOUNT = 'api-key-openai';
525
+ /** Keychain account for a provider key. Scalix uses the legacy default account;
526
+ * every other provider gets its own slot so keys never collide. */
527
+ export function providerKeychainAccount(provider) {
528
+ return provider === 'scalix' ? undefined : `api-key-${provider}`;
529
+ }
530
+ /** Every provider key that should appear in the picker: the built-in Scalix
531
+ * entry plus each configured registry entry, minus `disabledProviders` and
532
+ * entries disabled individually. */
533
+ export function listProviderKeys(config) {
534
+ const disabled = new Set(config.disabledProviders || []);
535
+ const keys = ['scalix'];
536
+ for (const [key, creds] of Object.entries(config.providers || {})) {
537
+ if (key === 'scalix')
538
+ continue;
539
+ if (creds.enabled === false)
540
+ continue;
541
+ if (disabled.has(key))
542
+ continue;
543
+ keys.push(key);
544
+ }
545
+ return keys;
546
+ }
547
+ /** Resolve one provider key from config only (no env override). Used for
548
+ * explicit per-session overrides, where the caller's choice must win over the
549
+ * process-wide `SCALIX_PROVIDER`. */
550
+ export function resolveProviderByKey(config, provider) {
551
+ if (provider !== 'scalix') {
552
+ const creds = config.providers?.[provider] ?? {};
553
+ return {
554
+ provider,
555
+ baseUrl: creds.baseUrl || '',
556
+ apiKey: creds.apiKey || '',
557
+ cloudBaseUrl: config.baseUrl,
558
+ cloudApiKey: config.apiKey || '',
559
+ };
560
+ }
561
+ return {
562
+ provider: 'scalix',
563
+ baseUrl: config.baseUrl,
564
+ apiKey: config.apiKey,
565
+ cloudBaseUrl: config.baseUrl,
566
+ cloudApiKey: config.apiKey,
567
+ };
568
+ }
569
+ /** Picker-facing provider list. Never includes keys — only identity/status. */
570
+ export function summarizeProviders(config) {
571
+ const out = [];
572
+ for (const key of listProviderKeys(config)) {
573
+ if (key === 'scalix') {
574
+ out.push({ id: 'scalix', name: 'Scalix AI', adapter: 'scalix', configured: !!config.apiKey, builtIn: true });
575
+ continue;
576
+ }
577
+ const creds = config.providers?.[key] ?? {};
578
+ out.push({
579
+ id: key,
580
+ name: creds.name || key,
581
+ adapter: 'openai',
582
+ configured: !!(creds.baseUrl && creds.apiKey),
583
+ builtIn: false,
584
+ });
585
+ }
586
+ return out;
587
+ }
450
588
  /**
451
589
  * Resolve which provider + credentials this session runs on.
452
590
  *
453
591
  * Precedence (mirrors the rest of config): env → config. `SCALIX_PROVIDER`
454
- * selects the provider; for `openai`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`
455
- * override `providers.openai`. Scalix stays the zero-config default and its
456
- * path is byte-identical to the pre-BYOK behavior. There is NO default
457
- * third-party URL — an openai selection without a baseUrl resolves with an
458
- * empty one and the caller surfaces the configuration error.
592
+ * selects the provider key (built-in or registry); for any non-scalix provider
593
+ * the generic `SCALIX_PROVIDER_BASE_URL` / `SCALIX_PROVIDER_API_KEY` env vars
594
+ * win, and for `openai` the standard `OPENAI_API_KEY` / `OPENAI_BASE_URL` are
595
+ * also honored. Scalix stays the zero-config default and its path is
596
+ * byte-identical to the pre-BYOK behavior. There is NO default third-party URL
597
+ * — a BYOK selection without a baseUrl resolves with an empty one and the
598
+ * caller surfaces the configuration error.
459
599
  *
460
600
  * Scalix Cloud credentials resolve separately so BYOK sessions keep the
461
- * platform tools: under `openai` they come from `SCALIX_CLOUD_API_KEY` →
462
- * `SCALIX_API_KEY` → the top-level `config.apiKey`, and are simply empty when
463
- * the user never added one.
601
+ * platform tools: they come from `SCALIX_CLOUD_API_KEY` → `SCALIX_API_KEY` →
602
+ * the top-level `config.apiKey`, and are simply empty when the user never
603
+ * added one.
464
604
  */
465
605
  export function resolveActiveProvider(config, env = process.env) {
466
606
  const envProvider = env.SCALIX_PROVIDER?.trim().toLowerCase();
@@ -469,14 +609,23 @@ export function resolveActiveProvider(config, env = process.env) {
469
609
  : (config.provider ?? 'scalix');
470
610
  const scalixBaseUrl = env.SCALIX_BASE_URL?.trim() || config.baseUrl;
471
611
  const scalixApiKey = env.SCALIX_API_KEY?.trim() || config.apiKey;
472
- if (provider === 'openai') {
473
- const creds = config.providers?.openai ?? {};
612
+ const cloudBaseUrl = env.SCALIX_CLOUD_BASE_URL?.trim() || scalixBaseUrl;
613
+ const cloudApiKey = env.SCALIX_CLOUD_API_KEY?.trim() || scalixApiKey || '';
614
+ if (provider !== 'scalix') {
615
+ const creds = config.providers?.[provider] ?? {};
616
+ const legacyOpenAI = provider === 'openai';
474
617
  return {
475
618
  provider,
476
- baseUrl: env.OPENAI_BASE_URL?.trim() || creds.baseUrl || '',
477
- apiKey: env.OPENAI_API_KEY?.trim() || creds.apiKey || '',
478
- cloudBaseUrl: env.SCALIX_CLOUD_BASE_URL?.trim() || scalixBaseUrl,
479
- cloudApiKey: env.SCALIX_CLOUD_API_KEY?.trim() || scalixApiKey || '',
619
+ baseUrl: env.SCALIX_PROVIDER_BASE_URL?.trim()
620
+ || (legacyOpenAI ? env.OPENAI_BASE_URL?.trim() : '')
621
+ || creds.baseUrl
622
+ || '',
623
+ apiKey: env.SCALIX_PROVIDER_API_KEY?.trim()
624
+ || (legacyOpenAI ? env.OPENAI_API_KEY?.trim() : '')
625
+ || creds.apiKey
626
+ || '',
627
+ cloudBaseUrl,
628
+ cloudApiKey,
480
629
  };
481
630
  }
482
631
  return {
@@ -42,6 +42,7 @@ export const SLASH_COMMANDS = [
42
42
  { cmd: '/update', desc: 'Check for and install updates' },
43
43
  { cmd: '/doctor', desc: 'Diagnose installation' },
44
44
  { cmd: '/mcp', desc: 'Add MCP server' },
45
+ { cmd: '/marketplace', desc: 'Browse & install from the Scalix curated catalog' },
45
46
  { cmd: '/cloud', desc: 'Scalix Cloud status, platform tools & refresh' },
46
47
  { cmd: '/login', desc: 'Link your Scalix account (API key)' },
47
48
  { cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
@@ -125,6 +126,7 @@ export const HELP_LINES = [
125
126
  ' /skills \u2014 list available skills',
126
127
  ' /setup \u2014 reconfigure API key, URL & models',
127
128
  ' /mcp <l> <u> \u2014 add MCP server',
129
+ ' /marketplace \u2014 browse/install from the Scalix curated catalog (MCP, skills, rules, subagents)',
128
130
  ' /plugins \u2014 show loaded plugins',
129
131
  ' /plugin \u2014 install/enable/disable/uninstall plugins',
130
132
  ' /trust \u2014 trust workspace (load project plugins/hooks/rules)',
package/dist/sub-agent.js CHANGED
@@ -69,6 +69,8 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
69
69
  apiKey: parentConfig.apiKey,
70
70
  baseUrl: parentConfig.baseUrl,
71
71
  provider: parentConfig.provider,
72
+ cloudApiKey: parentConfig.cloudApiKey,
73
+ cloudBaseUrl: parentConfig.cloudBaseUrl,
72
74
  model,
73
75
  instructions: options.instructions || 'You are a focused research assistant. Be concise and factual. Respond in under 500 words.',
74
76
  tools,
@@ -145,6 +147,7 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
145
147
  merged: mergeResult.merged,
146
148
  commits: mergeResult.commitCount,
147
149
  conflicts: mergeResult.conflicts?.length ?? 0,
150
+ error: mergeResult.error,
148
151
  });
149
152
  }
150
153
  catch (err) {
@@ -467,10 +467,16 @@ export const browserGetTextTool = tool({
467
467
  },
468
468
  });
469
469
  const SESSION_DIR = '.scalix/browser-sessions';
470
+ // Session names become file names under .scalix/browser-sessions/ — restrict
471
+ // them to a safe charset so `../` cannot escape the sandbox root.
472
+ const SESSION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
470
473
  async function saveSessionState(name) {
471
474
  if (!browserContext || !browserPage) {
472
475
  throw new Error('No active browser session to save');
473
476
  }
477
+ if (!SESSION_NAME_RE.test(name)) {
478
+ throw new Error(`Invalid session name "${name}" — use letters, digits, dot, dash, underscore`);
479
+ }
474
480
  const storageState = await browserContext.storageState();
475
481
  const url = browserPage.url();
476
482
  const sessionDir = path.join(getSandboxRoot(), SESSION_DIR);
@@ -496,7 +502,7 @@ export const browserSessionSaveTool = tool({
496
502
  name: 'browser_session_save',
497
503
  description: 'Save the current browser session (cookies, localStorage, URL) to disk for later restoration.',
498
504
  inputSchema: z.object({
499
- name: z.string().max(100).describe('Session name (used to restore later)'),
505
+ name: z.string().max(100).regex(SESSION_NAME_RE, 'Session name may only contain letters, digits, dot, dash, underscore (no slashes)').describe('Session name (used to restore later)'),
500
506
  }),
501
507
  execute: async ({ name }) => {
502
508
  try {
@@ -512,7 +518,7 @@ export const browserSessionRestoreTool = tool({
512
518
  name: 'browser_session_restore',
513
519
  description: 'Restore a previously saved browser session. Recreates the browser context with saved cookies/localStorage and navigates to the saved URL.',
514
520
  inputSchema: z.object({
515
- name: z.string().max(100).describe('Session name to restore'),
521
+ name: z.string().max(100).regex(SESSION_NAME_RE, 'Session name may only contain letters, digits, dot, dash, underscore (no slashes)').describe('Session name to restore'),
516
522
  }),
517
523
  execute: async ({ name }) => {
518
524
  const pw = await getPlaywright();
@@ -102,7 +102,11 @@ export const searchFilesTool = tool({
102
102
  const beforeContext = args['-B'];
103
103
  try {
104
104
  const cwd = process.cwd();
105
- const target = searchPath || '.';
105
+ // Contain the search target before ANY backend runs — the rg fast path
106
+ // takes arbitrary paths and would otherwise read outside the sandbox
107
+ // (the built-in fallback below already validates).
108
+ const safeTarget = searchPath ? sandboxPath(searchPath) : '.';
109
+ const target = safeTarget;
106
110
  // Try ripgrep first
107
111
  const rgArgs = [];
108
112
  if (outputMode === 'files_with_matches')
@@ -136,18 +140,34 @@ export const searchFilesTool = tool({
136
140
  });
137
141
  const lines = stdout.split('\n').filter(Boolean);
138
142
  const sliced = lines.slice(offset, headLimit > 0 ? offset + headLimit : undefined);
143
+ // The target is resolved to an absolute sandbox path, so rg prints
144
+ // absolute paths — render them relative to cwd like the fallback does.
145
+ const relPath = (p) => {
146
+ const abs = path.isAbsolute(p) ? p : path.resolve(cwd, p);
147
+ return path.relative(cwd, abs) || '.';
148
+ };
149
+ const splitPrefix = (line, parts) => {
150
+ const segs = line.split(':');
151
+ const file = segs.slice(0, segs.length - parts).join(':');
152
+ const rest = segs.slice(segs.length - parts).join(':');
153
+ return [relPath(file), rest];
154
+ };
139
155
  if (outputMode === 'files_with_matches') {
140
- return { pattern, count: sliced.length, files: sliced };
156
+ return { pattern, count: sliced.length, files: sliced.map(relPath) };
141
157
  }
142
158
  else if (outputMode === 'count') {
143
159
  const counts = sliced.map(line => {
144
- const [file, cnt] = line.split(':');
160
+ const [file, cnt] = splitPrefix(line, 1);
145
161
  return { file, count: parseInt(cnt, 10) || 0 };
146
162
  });
147
163
  return { pattern, entries: counts };
148
164
  }
149
165
  else {
150
- return { pattern, matchCount: sliced.length, content: sliced.join('\n') };
166
+ const contentLines = sliced.map(line => {
167
+ const [file, rest] = splitPrefix(line, 2);
168
+ return `${file}:${rest}`;
169
+ });
170
+ return { pattern, matchCount: sliced.length, content: contentLines.join('\n') };
151
171
  }
152
172
  }
153
173
  catch (rgErr) {
@@ -339,6 +359,66 @@ export const findFilesTool = tool({
339
359
  }
340
360
  },
341
361
  });
362
+ // Artifacts — the Antigravity-parity surface: reviewable, durable outputs a
363
+ // turn produces (plan, walkthrough, task list, summary) instead of burying
364
+ // them in chat scrollback. Files live under <workspace>/.scalix/artifacts/ so
365
+ // they are plain markdown any editor can open, with frontmatter the UI reads.
366
+ const ARTIFACT_TYPES = ['plan', 'walkthrough', 'tasklist', 'summary'];
367
+ export const writeArtifactTool = tool({
368
+ name: 'write_artifact',
369
+ description: 'Write a reviewable artifact for this task: an implementation plan before multi-step work, a walkthrough of what was done after it, a task list, or a summary. ' +
370
+ 'Markdown with a # title. Returns the artifact path.',
371
+ inputSchema: z.object({
372
+ type: z.enum(ARTIFACT_TYPES).describe('plan | walkthrough | tasklist | summary'),
373
+ title: z.string().min(1).max(120).describe('Short human title, e.g. "Auth refactor plan"'),
374
+ content: z.string().min(1).max(100_000).describe('Markdown body (no frontmatter — added automatically).'),
375
+ }),
376
+ execute: async ({ type, title, content }) => {
377
+ try {
378
+ const dir = sandboxPath('.scalix/artifacts');
379
+ await fs.mkdir(dir, { recursive: true });
380
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'artifact';
381
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
382
+ const rel = `.scalix/artifacts/${stamp}-${type}-${slug}.md`;
383
+ const abs = sandboxPath(rel);
384
+ const frontmatter = `---\ntype: ${type}\ntitle: ${title.replace(/\n/g, ' ')}\ncreatedAt: ${new Date().toISOString()}\n---\n\n`;
385
+ await fs.writeFile(abs, frontmatter + content + (content.endsWith('\n') ? '' : '\n'), 'utf8');
386
+ return { ok: true, path: rel, type, title };
387
+ }
388
+ catch (error) {
389
+ return { error: error.message };
390
+ }
391
+ },
392
+ });
393
+ export const listArtifactsTool = tool({
394
+ name: 'list_artifacts',
395
+ description: 'List artifacts previously written in this workspace (.scalix/artifacts), newest first.',
396
+ inputSchema: z.object({}),
397
+ execute: async () => {
398
+ try {
399
+ const dir = sandboxPath('.scalix/artifacts');
400
+ const names = await fs.readdir(dir);
401
+ const files = names.filter((n) => n.endsWith('.md')).sort().reverse().slice(0, 50);
402
+ const artifacts = await Promise.all(files.map(async (n) => {
403
+ try {
404
+ const head = (await fs.readFile(path.join(dir, n), 'utf8')).slice(0, 512);
405
+ const type = head.match(/^type:\s*(\S+)/m)?.[1] ?? 'unknown';
406
+ const title = head.match(/^title:\s*(.+)$/m)?.[1] ?? n;
407
+ return { path: `.scalix/artifacts/${n}`, type, title };
408
+ }
409
+ catch {
410
+ return { path: `.scalix/artifacts/${n}`, type: 'unknown', title: n };
411
+ }
412
+ }));
413
+ return { artifacts };
414
+ }
415
+ catch (error) {
416
+ if (error.code === 'ENOENT')
417
+ return { artifacts: [] };
418
+ return { error: error.message };
419
+ }
420
+ },
421
+ });
342
422
  export const gitStatusTool = tool({
343
423
  name: 'git_status',
344
424
  description: 'Get git repository status: current branch, staged/unstaged changes, untracked files.',
@@ -426,6 +506,8 @@ export const extraTools = [
426
506
  editFileTool,
427
507
  searchFilesTool,
428
508
  findFilesTool,
509
+ writeArtifactTool,
510
+ listArtifactsTool,
429
511
  gitStatusTool,
430
512
  gitDiffTool,
431
513
  gitCommitTool,