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.
package/dist/tools-web.js CHANGED
@@ -2,7 +2,7 @@ import { tool } from './tool-helper.js';
2
2
  import { z } from 'zod';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import dns from 'node:dns';
5
+ import { resolveLookupAddresses, pinnedFetch } from './secure-fetch.js';
6
6
  import { sandboxPath } from './sandbox.js';
7
7
  import { logger } from './logger.js';
8
8
  import { USER_AGENT, SEARCH_ENDPOINTS, WEB_DEFAULTS, TOOL_DEFAULTS, HTML_STRIP_TAGS, isPrivateHost, isPrivateIp, isHostExplicitlyAllowed, } from './constants.js';
@@ -11,29 +11,29 @@ import { DEFAULT_LIMITS } from './settings.js';
11
11
  const MAX_FETCH_REDIRECTS = 5;
12
12
  // Verify a URL is safe to fetch: http/https only, non-private hostname, and no
13
13
  // resolved IP that lands in a private/loopback/link-local range (defeats DNS
14
- // rebinding). Returns an error string when the URL must be blocked, else null.
14
+ // rebinding). The addresses that PASSED the check are returned so the actual
15
+ // connection can be pinned to them — otherwise the fetch re-resolves the name
16
+ // and a rebinding answer lands the request inside the private network the
17
+ // check just cleared.
15
18
  async function validateFetchTarget(target) {
16
19
  if (target.protocol !== 'http:' && target.protocol !== 'https:') {
17
- return `Blocked: only http/https URLs are allowed (got ${target.protocol})`;
20
+ return { blocked: `Blocked: only http/https URLs are allowed (got ${target.protocol})`, addresses: [] };
18
21
  }
19
22
  const hostname = target.hostname.toLowerCase();
20
23
  if (isPrivateHost(hostname)) {
21
- return 'Blocked: requests to localhost and private networks are not allowed';
24
+ return { blocked: 'Blocked: requests to localhost and private networks are not allowed', addresses: [] };
22
25
  }
23
26
  // An operator who allow-listed this exact name did so knowing it points into
24
27
  // a private range; re-checking the resolved address would only undo that.
25
28
  if (isHostExplicitlyAllowed(hostname))
26
- return null;
27
- try {
28
- const addresses = await dns.promises.lookup(hostname, { all: true });
29
- if (addresses.some((a) => isPrivateIp(a.address))) {
30
- return 'Blocked: hostname resolves to a private network address';
31
- }
29
+ return { blocked: null, addresses: [] };
30
+ const addresses = await resolveLookupAddresses(hostname);
31
+ if (addresses.length > 0 && addresses.some((a) => isPrivateIp(a.address))) {
32
+ return { blocked: 'Blocked: hostname resolves to a private network address', addresses: [] };
32
33
  }
33
- catch {
34
- // DNS failure will surface as a normal fetch error below; don't block here.
35
- }
36
- return null;
34
+ // A DNS failure is left to the request itself to report — and pins to the
35
+ // (empty) address list, so plain fetch surfaces the usual error.
36
+ return { blocked: null, addresses };
37
37
  }
38
38
  let _limits = DEFAULT_LIMITS;
39
39
  export function setWebToolLimits(limits) { _limits = limits; }
@@ -88,20 +88,23 @@ export const webFetchTool = tool({
88
88
  // SSRF checks (a 3xx to a private host would otherwise bypass them).
89
89
  let currentUrl = parsed;
90
90
  let response;
91
+ const requestInit = {
92
+ signal: controller.signal,
93
+ redirect: 'manual',
94
+ headers: {
95
+ 'User-Agent': `Mozilla/5.0 (compatible; ${USER_AGENT})`,
96
+ 'Accept': 'text/html,application/json,text/plain;q=0.9,*/*;q=0.8',
97
+ },
98
+ };
91
99
  try {
92
100
  let hops = 0;
93
101
  while (true) {
94
- const blocked = await validateFetchTarget(currentUrl);
102
+ const { blocked, addresses } = await validateFetchTarget(currentUrl);
95
103
  if (blocked)
96
104
  return { error: blocked, url: currentUrl.href };
97
- response = await fetch(currentUrl.href, {
98
- signal: controller.signal,
99
- redirect: 'manual',
100
- headers: {
101
- 'User-Agent': `Mozilla/5.0 (compatible; ${USER_AGENT})`,
102
- 'Accept': 'text/html,application/json,text/plain;q=0.9,*/*;q=0.8',
103
- },
104
- });
105
+ // Pinned to the addresses the SSRF check just validated, so the
106
+ // connection cannot be re-pointed between the check and the dial.
107
+ response = await pinnedFetch(currentUrl, requestInit, addresses);
105
108
  if (response.status >= 300 && response.status < 400) {
106
109
  const location = response.headers.get('location');
107
110
  if (!location)
package/dist/tools.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { tool } from './tool-helper.js';
2
2
  import { z } from 'zod';
3
- import { exec, spawn } from 'node:child_process';
3
+ import { exec, execFile, spawn } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import fsSync from 'node:fs';
6
6
  import fs from 'node:fs/promises';
7
+ import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import { safeEvaluate } from './safe-math.js';
9
10
  import { sandboxPath, isBlockedCommand, detectInjection, sanitizeEnvForCommand, getShellInfo, isWithinRoot, getSandboxRoot } from './sandbox.js';
@@ -11,9 +12,43 @@ import { DEFAULT_LIMITS } from './settings.js';
11
12
  import { TOOL_DEFAULTS, BINARY_DETECT_BUFFER_SIZE } from './constants.js';
12
13
  import { computeDiff, formatDiff } from './diff.js';
13
14
  import { normalizeMcpConfig, validateMcpUrl } from './mcp.js';
15
+ import { buildSandboxWrap, materializeWrap } from './command-sandbox.js';
14
16
  let _limits = DEFAULT_LIMITS;
15
17
  export function setToolLimits(limits) { _limits = limits; }
18
+ // OS-level command sandbox (command-sandbox.ts). Off until something wires it:
19
+ // the Agent constructor pushes the resolved config mode here, so every front
20
+ // end (CLI, IPC, headless) inherits the same policy with no per-entry plumbing.
21
+ let _sandboxMode = 'off';
22
+ export function setCommandSandboxMode(mode) { _sandboxMode = mode; }
23
+ export function getCommandSandboxMode() { return _sandboxMode; }
16
24
  const execAsync = promisify(exec);
25
+ const execFileAsync = promisify(execFile);
26
+ /**
27
+ * Run a command string inside the OS sandbox when enabled. Returns the wrap's
28
+ * exec argv, or null when the sandbox is off/unavailable (with a reason).
29
+ * The profile file (darwin) is written next to the process and unlinked by the
30
+ * caller after the child exits.
31
+ */
32
+ async function sandboxedArgv(command, workingDir) {
33
+ if (_sandboxMode === 'off')
34
+ return { error: 'sandbox disabled' };
35
+ const shell = getShellInfo();
36
+ const built = buildSandboxWrap(_sandboxMode, workingDir, shell);
37
+ if (!built.ok)
38
+ return { error: built.error };
39
+ let profilePath;
40
+ if (built.wrap.profile) {
41
+ try {
42
+ profilePath = path.join(os.tmpdir(), `scalix-sandbox-${process.pid}-${Date.now()}.sb`);
43
+ await fs.writeFile(profilePath, built.wrap.profile, { mode: 0o600 });
44
+ }
45
+ catch (err) {
46
+ return { error: `could not write the sandbox profile: ${err.message}` };
47
+ }
48
+ }
49
+ const { execPath, args } = materializeWrap(built.wrap, command, profilePath);
50
+ return { execPath, args, profilePath };
51
+ }
17
52
  let persistentCwd = process.cwd();
18
53
  export function getPersistentCwd() { return persistentCwd; }
19
54
  export function setPersistentCwd(dir) { persistentCwd = dir; }
@@ -94,12 +129,25 @@ export const runCommandTool = tool({
94
129
  }
95
130
  const shell = getShellInfo();
96
131
  if (runInBg) {
97
- const child = spawn(shell.path, [...shell.args, command], {
98
- detached: true,
99
- stdio: ['ignore', 'pipe', 'pipe'],
100
- env: sanitizeEnvForCommand(),
101
- cwd: workingDir,
102
- });
132
+ const bg = _sandboxMode === 'off'
133
+ ? null
134
+ : await sandboxedArgv(command, workingDir);
135
+ if (bg && 'error' in bg) {
136
+ return { error: `Command sandbox unavailable: ${bg.error}`, exitCode: -1 };
137
+ }
138
+ const child = bg
139
+ ? spawn(bg.execPath, bg.args, {
140
+ detached: true,
141
+ stdio: ['ignore', 'pipe', 'pipe'],
142
+ env: sanitizeEnvForCommand(),
143
+ cwd: workingDir,
144
+ })
145
+ : spawn(shell.path, [...shell.args, command], {
146
+ detached: true,
147
+ stdio: ['ignore', 'pipe', 'pipe'],
148
+ env: sanitizeEnvForCommand(),
149
+ cwd: workingDir,
150
+ });
103
151
  if (!child.pid) {
104
152
  return { error: 'Failed to spawn background process: no PID assigned', exitCode: -1 };
105
153
  }
@@ -181,13 +229,40 @@ export const runCommandTool = tool({
181
229
  fullCommand = cwdProbe;
182
230
  }
183
231
  try {
184
- const { stdout, stderr } = await execAsync(fullCommand, {
232
+ let stdout;
233
+ let stderr;
234
+ const execOpts = {
185
235
  timeout: timeout ?? _limits.commandTimeoutMs,
186
236
  maxBuffer: TOOL_DEFAULTS.maxExecBufferDefault,
187
237
  env: sanitizeEnvForCommand(),
188
238
  cwd: workingDir,
189
239
  shell: shell.path,
190
- });
240
+ };
241
+ if (_sandboxMode === 'off') {
242
+ ({ stdout, stderr } = await execAsync(fullCommand, execOpts));
243
+ }
244
+ else {
245
+ // Kernel-enforced confinement: sandbox-exec (seatbelt) or bwrap execs
246
+ // the same shell with the same command string, so shell semantics are
247
+ // unchanged — what changes is what the child is allowed to touch.
248
+ const sb = await sandboxedArgv(fullCommand, workingDir);
249
+ if ('error' in sb) {
250
+ return { error: `Command sandbox unavailable: ${sb.error}`, exitCode: -1 };
251
+ }
252
+ try {
253
+ ({ stdout, stderr } = await execFileAsync(sb.execPath, sb.args, {
254
+ timeout: execOpts.timeout,
255
+ maxBuffer: execOpts.maxBuffer,
256
+ env: execOpts.env,
257
+ cwd: execOpts.cwd,
258
+ }));
259
+ }
260
+ finally {
261
+ if (sb.profilePath) {
262
+ fs.unlink(sb.profilePath).catch(() => { });
263
+ }
264
+ }
265
+ }
191
266
  if (cdTarget && fullCommand !== command) {
192
267
  const markerIdx = stdout.indexOf(CWD_MARKER);
193
268
  if (markerIdx !== -1) {
@@ -49,7 +49,11 @@ export function hasProjectExecSurface(cwd = process.cwd()) {
49
49
  // repo can carry MCP config without a root-level file. Listing only the
50
50
  // root filename meant an MCP-only clone looked inert and the user was
51
51
  // never offered the trust prompt for the one thing it did have.
52
- path.join(dir, 'mcp-services.json'), path.join(dir, 'rules'), path.join(dir, 'settings.local.json'));
52
+ path.join(dir, 'mcp-services.json'), path.join(dir, 'rules'), path.join(dir, 'settings.local.json'),
53
+ // Command .md files may carry !`cmd` inline-bash expansions, which run
54
+ // shell commands — an exec surface like hooks, so they warrant the
55
+ // trust prompt too.
56
+ path.join(dir, 'commands'));
53
57
  }
54
58
  return candidates.some(rel => {
55
59
  try {
package/dist/worktree.js CHANGED
@@ -51,7 +51,7 @@ export async function createWorktree(agentId, basePath) {
51
51
  createdAt: Date.now(),
52
52
  };
53
53
  }
54
- export async function removeWorktree(info, basePath) {
54
+ export async function removeWorktree(info, basePath, options = {}) {
55
55
  try {
56
56
  await execFileAsync('git', ['worktree', 'remove', info.path, '--force'], {
57
57
  cwd: basePath,
@@ -68,14 +68,31 @@ export async function removeWorktree(info, basePath) {
68
68
  // best effort
69
69
  }
70
70
  }
71
+ // `git branch -d` refuses to delete a branch with unmerged commits — the
72
+ // safe default. Only fall back to `-D` when the caller explicitly asked for
73
+ // a forced delete; otherwise keep the branch so the sub-agent's commits
74
+ // stay recoverable.
71
75
  try {
72
- await execFileAsync('git', ['branch', '-D', info.branch], {
76
+ await execFileAsync('git', ['branch', '-d', info.branch], {
73
77
  cwd: basePath,
74
78
  timeout: 10_000,
75
79
  });
76
80
  }
77
81
  catch {
78
- // branch may already be deleted
82
+ if (options.forceBranch) {
83
+ try {
84
+ await execFileAsync('git', ['branch', '-D', info.branch], {
85
+ cwd: basePath,
86
+ timeout: 10_000,
87
+ });
88
+ }
89
+ catch {
90
+ // branch may already be deleted
91
+ }
92
+ }
93
+ else {
94
+ logger.info('Kept unmerged worktree branch for recovery', { agentId: info.agentId, branch: info.branch });
95
+ }
79
96
  }
80
97
  logger.info('Removed worktree', { agentId: info.agentId, path: info.path });
81
98
  }
@@ -109,8 +126,10 @@ export async function mergeWorktree(info, basePath, targetBranch) {
109
126
  return { merged: false, conflicts, commitCount: commits.length };
110
127
  }
111
128
  }
112
- catch {
113
- return { merged: true, commitCount: 0 };
129
+ catch (err) {
130
+ // The probe itself failed (detached HEAD, missing ref, timeout) — report
131
+ // a real failure instead of pretending the merge was a clean no-op.
132
+ return { merged: false, commitCount: 0, error: err?.message || String(err) };
114
133
  }
115
134
  }
116
135
  export async function listWorktrees(basePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalix",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "AI coding agent for the terminal — edit code, run commands, and build with Scalix",
5
5
  "main": "dist/headless.js",
6
6
  "bin": {
@@ -73,12 +73,12 @@
73
73
  "@types/react": "^19.2.14",
74
74
  "@typescript-eslint/eslint-plugin": "^8.0.0",
75
75
  "@typescript-eslint/parser": "^8.0.0",
76
- "@vitest/coverage-v8": "^3.2.4",
76
+ "@vitest/coverage-v8": "^4.1.11",
77
77
  "eslint": "^9.0.0",
78
78
  "playwright": "^1.59.1",
79
79
  "tsx": "^4.21.0",
80
80
  "typescript": "^6.0.2",
81
- "vitest": "^3.0.0"
81
+ "vitest": "^4.1.11"
82
82
  },
83
83
  "optionalDependencies": {
84
84
  "playwright": "^1.59.1"