scalix 1.0.0 → 1.2.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/sandbox.js CHANGED
@@ -45,7 +45,75 @@ export async function sandboxPathAsync(filePath) {
45
45
  throw err;
46
46
  }
47
47
  }
48
- const READ_ONLY_TOOLS = new Set([
48
+ let _scopedRules = [];
49
+ export function setScopedPermissionRules(rules) {
50
+ _scopedRules = rules;
51
+ }
52
+ export function getScopedPermissionRules() {
53
+ return _scopedRules;
54
+ }
55
+ function parseScopedToolSpec(spec) {
56
+ const match = spec.match(/^(\w+)\(([^)]+)\)$/);
57
+ if (match)
58
+ return { tool: match[1], prefix: match[2].replace(/\*$/, '') };
59
+ return { tool: spec };
60
+ }
61
+ export function checkScopedPermission(toolName, args) {
62
+ if (_scopedRules.length === 0)
63
+ return null;
64
+ const argStr = extractPrimaryArg(toolName, args);
65
+ for (const rule of _scopedRules) {
66
+ const parsed = parseScopedToolSpec(rule.tool);
67
+ if (!parsed)
68
+ continue;
69
+ if (parsed.tool !== toolName)
70
+ continue;
71
+ if (!parsed.prefix)
72
+ return rule.action;
73
+ if (argStr && argStr.startsWith(parsed.prefix))
74
+ return rule.action;
75
+ if (rule.regex) {
76
+ try {
77
+ if (new RegExp(rule.regex).test(argStr || ''))
78
+ return rule.action;
79
+ }
80
+ catch { }
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ function extractPrimaryArg(toolName, args) {
86
+ if (!args || typeof args !== 'object')
87
+ return null;
88
+ const a = args;
89
+ if (toolName === 'run_command' && typeof a.command === 'string')
90
+ return a.command;
91
+ if (typeof a.filePath === 'string')
92
+ return a.filePath;
93
+ if (typeof a.file_path === 'string')
94
+ return a.file_path;
95
+ if (typeof a.path === 'string')
96
+ return a.path;
97
+ if (typeof a.pattern === 'string')
98
+ return a.pattern;
99
+ if (typeof a.query === 'string')
100
+ return a.query;
101
+ if (typeof a.url === 'string')
102
+ return a.url;
103
+ const first = Object.values(a).find(v => typeof v === 'string');
104
+ return typeof first === 'string' ? first : null;
105
+ }
106
+ // ─── Security Configuration ───
107
+ let _securityConfig = {};
108
+ export function setSecurityConfig(config) {
109
+ _securityConfig = config;
110
+ _effectiveToolSets = null; // invalidate cache
111
+ }
112
+ export function getSecurityConfig() {
113
+ return _securityConfig;
114
+ }
115
+ // ─── Tool Classification (configurable) ───
116
+ const DEFAULT_READ_ONLY_TOOLS = new Set([
49
117
  'get_current_time',
50
118
  'calculate',
51
119
  'read_file',
@@ -59,31 +127,76 @@ const READ_ONLY_TOOLS = new Set([
59
127
  'web_search',
60
128
  'monitor',
61
129
  ]);
62
- const WRITE_TOOLS = new Set([
130
+ const DEFAULT_WRITE_TOOLS = new Set([
63
131
  'write_file',
64
132
  'edit_file',
65
133
  'web_fetch',
66
134
  'todo_write',
67
135
  'notebook_edit',
136
+ 'send_message',
137
+ 'task_create',
138
+ 'task_claim',
139
+ 'task_update',
140
+ 'task_list',
68
141
  ]);
69
- const DANGEROUS_TOOLS = new Set([
142
+ const DEFAULT_DANGEROUS_TOOLS = new Set([
70
143
  'run_command',
71
144
  'git_commit',
72
145
  'add_mcp_server',
73
146
  'agent',
74
147
  ]);
148
+ let _effectiveToolSets = null;
149
+ function getEffectiveToolSets() {
150
+ if (_effectiveToolSets)
151
+ return _effectiveToolSets;
152
+ const readOnly = new Set(DEFAULT_READ_ONLY_TOOLS);
153
+ const write = new Set(DEFAULT_WRITE_TOOLS);
154
+ const dangerous = new Set(DEFAULT_DANGEROUS_TOOLS);
155
+ if (_securityConfig.additionalReadOnlyTools) {
156
+ for (const t of _securityConfig.additionalReadOnlyTools)
157
+ readOnly.add(t);
158
+ }
159
+ if (_securityConfig.additionalWriteTools) {
160
+ for (const t of _securityConfig.additionalWriteTools)
161
+ write.add(t);
162
+ }
163
+ if (_securityConfig.additionalDangerousTools) {
164
+ for (const t of _securityConfig.additionalDangerousTools)
165
+ dangerous.add(t);
166
+ }
167
+ if (_securityConfig.toolOverrides) {
168
+ for (const [tool, level] of Object.entries(_securityConfig.toolOverrides)) {
169
+ readOnly.delete(tool);
170
+ write.delete(tool);
171
+ dangerous.delete(tool);
172
+ if (level === 'read-only')
173
+ readOnly.add(tool);
174
+ else if (level === 'write')
175
+ write.add(tool);
176
+ else if (level === 'dangerous')
177
+ dangerous.add(tool);
178
+ }
179
+ }
180
+ _effectiveToolSets = { readOnly, write, dangerous };
181
+ return _effectiveToolSets;
182
+ }
183
+ // Keep backward-compatible exports
184
+ const READ_ONLY_TOOLS = DEFAULT_READ_ONLY_TOOLS;
185
+ const WRITE_TOOLS = DEFAULT_WRITE_TOOLS;
186
+ const DANGEROUS_TOOLS = DEFAULT_DANGEROUS_TOOLS;
75
187
  export function getToolPermission(toolName, mode) {
188
+ const sets = getEffectiveToolSets();
76
189
  switch (mode) {
77
190
  case 'plan':
78
- return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'block';
191
+ return sets.readOnly.has(toolName) ? 'allow' : 'block';
79
192
  case 'default':
80
- if (READ_ONLY_TOOLS.has(toolName))
193
+ if (sets.readOnly.has(toolName))
81
194
  return 'allow';
82
195
  return 'ask';
83
196
  case 'auto':
84
- if (READ_ONLY_TOOLS.has(toolName))
197
+ if (sets.readOnly.has(toolName))
85
198
  return 'allow';
86
- if (WRITE_TOOLS.has(toolName))
199
+ if (sets.write.has(toolName))
87
200
  return 'allow';
88
201
  return 'ask';
89
202
  case 'yolo':
@@ -92,8 +205,17 @@ export function getToolPermission(toolName, mode) {
92
205
  return 'ask';
93
206
  }
94
207
  }
208
+ export function getToolPermissionWithArgs(toolName, mode, args) {
209
+ const scoped = checkScopedPermission(toolName, args);
210
+ if (scoped === 'allow')
211
+ return 'allow';
212
+ if (scoped === 'deny')
213
+ return 'block';
214
+ return getToolPermission(toolName, mode);
215
+ }
95
216
  export function isDangerousTool(toolName) {
96
- return DANGEROUS_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName);
217
+ const sets = getEffectiveToolSets();
218
+ return sets.dangerous.has(toolName) || sets.write.has(toolName);
97
219
  }
98
220
  export function getModeDescription(mode) {
99
221
  switch (mode) {
@@ -255,11 +377,63 @@ const INJECTION_PATTERNS = [
255
377
  const SENSITIVE_ENV_PATTERNS = [
256
378
  /API_KEY/i, /SECRET/i, /TOKEN/i, /PASSWORD/i, /CREDENTIAL/i, /AUTH/i, /PRIVATE/i,
257
379
  ];
380
+ function getEffectiveBlocklist() {
381
+ const disabled = new Set(_securityConfig.disabledBlockedCommands || []);
382
+ const base = disabled.size > 0
383
+ ? COMMAND_BLOCKLIST.filter(re => !disabled.has(re.source))
384
+ : COMMAND_BLOCKLIST;
385
+ const additional = (_securityConfig.additionalBlockedCommands || []).map(p => {
386
+ try {
387
+ return new RegExp(p);
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }).filter((r) => r !== null);
393
+ return [...base, ...additional];
394
+ }
395
+ function getEffectiveInjectionPatterns() {
396
+ const disabled = new Set(_securityConfig.disabledInjectionPatterns || []);
397
+ const base = disabled.size > 0
398
+ ? INJECTION_PATTERNS.filter(re => !disabled.has(re.source))
399
+ : INJECTION_PATTERNS;
400
+ const additional = (_securityConfig.additionalInjectionPatterns || []).map(p => {
401
+ try {
402
+ return new RegExp(p);
403
+ }
404
+ catch {
405
+ return null;
406
+ }
407
+ }).filter((r) => r !== null);
408
+ return [...base, ...additional];
409
+ }
410
+ function getEffectiveEnvPatterns() {
411
+ const additional = (_securityConfig.additionalSensitiveEnvPatterns || []).map(p => {
412
+ try {
413
+ return new RegExp(p);
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }).filter((r) => r !== null);
419
+ const allowedPatterns = (_securityConfig.allowedEnvPatterns || []).map(p => {
420
+ try {
421
+ return new RegExp(p);
422
+ }
423
+ catch {
424
+ return null;
425
+ }
426
+ }).filter((r) => r !== null);
427
+ return {
428
+ sensitive: [...SENSITIVE_ENV_PATTERNS, ...additional],
429
+ allowed: new Set(), // will check against allowedPatterns dynamically
430
+ };
431
+ }
258
432
  export function isBlockedCommand(command) {
259
- return COMMAND_BLOCKLIST.some((re) => re.test(command));
433
+ return getEffectiveBlocklist().some((re) => re.test(command));
260
434
  }
261
435
  export function detectInjection(command) {
262
- for (const pattern of INJECTION_PATTERNS) {
436
+ for (const pattern of getEffectiveInjectionPatterns()) {
263
437
  if (pattern.test(command)) {
264
438
  return `Suspicious pattern detected: ${pattern.source}`;
265
439
  }
@@ -267,13 +441,29 @@ export function detectInjection(command) {
267
441
  return null;
268
442
  }
269
443
  export function sanitizeEnvForCommand() {
444
+ const { sensitive } = getEffectiveEnvPatterns();
445
+ const allowedPatterns = (_securityConfig.allowedEnvPatterns || []).map(p => {
446
+ try {
447
+ return new RegExp(p);
448
+ }
449
+ catch {
450
+ return null;
451
+ }
452
+ }).filter((r) => r !== null);
270
453
  const clean = {};
271
454
  for (const [key, val] of Object.entries(process.env)) {
272
455
  if (val === undefined)
273
456
  continue;
274
- if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key)))
457
+ if (sensitive.some((p) => p.test(key))) {
458
+ if (allowedPatterns.length > 0 && allowedPatterns.some(p => p.test(key))) {
459
+ clean[key] = val;
460
+ }
275
461
  continue;
462
+ }
276
463
  clean[key] = val;
277
464
  }
278
465
  return clean;
279
466
  }
467
+ export function getMaxImageSize() {
468
+ return _securityConfig.maxImageSize ?? 20 * 1024 * 1024;
469
+ }
package/dist/sessions.js CHANGED
@@ -5,8 +5,34 @@ import { randomUUID } from 'node:crypto';
5
5
  import { logger } from './logger.js';
6
6
  import { USER_CONFIG_DIR } from './constants.js';
7
7
  const SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'sessions');
8
+ let cleanupDone = false;
9
+ async function cleanupStaleTempFiles() {
10
+ if (cleanupDone)
11
+ return;
12
+ cleanupDone = true;
13
+ try {
14
+ const tmpDir = os.tmpdir();
15
+ const entries = await fs.readdir(tmpDir);
16
+ const oneHourAgo = Date.now() - 3600_000;
17
+ for (const entry of entries) {
18
+ if (!entry.startsWith('scalix-session-') || !entry.endsWith('.tmp'))
19
+ continue;
20
+ try {
21
+ const fullPath = path.join(tmpDir, entry);
22
+ const stat = await fs.stat(fullPath);
23
+ if (stat.mtimeMs < oneHourAgo) {
24
+ await fs.unlink(fullPath);
25
+ logger.debug('Cleaned up stale temp file', { file: entry });
26
+ }
27
+ }
28
+ catch { }
29
+ }
30
+ }
31
+ catch { }
32
+ }
8
33
  async function ensureDir() {
9
34
  await fs.mkdir(SESSIONS_DIR, { recursive: true });
35
+ await cleanupStaleTempFiles();
10
36
  }
11
37
  function sessionPath(id) {
12
38
  return path.join(SESSIONS_DIR, `${id}.json`);
@@ -32,12 +58,19 @@ export async function saveSession(id, messages, model, name, costState) {
32
58
  }
33
59
  export async function loadSession(id) {
34
60
  const raw = await fs.readFile(sessionPath(id), 'utf-8');
61
+ let data;
35
62
  try {
36
- return JSON.parse(raw);
63
+ data = JSON.parse(raw);
37
64
  }
38
65
  catch {
39
66
  throw new Error(`Corrupted session file: ${id}`);
40
67
  }
68
+ if (!data || typeof data !== 'object' ||
69
+ !('meta' in data) || !('messages' in data) ||
70
+ !Array.isArray(data.messages)) {
71
+ throw new Error(`Invalid session structure: ${id}`);
72
+ }
73
+ return data;
41
74
  }
42
75
  export async function listSessions() {
43
76
  await ensureDir();
@@ -49,7 +82,9 @@ export async function listSessions() {
49
82
  try {
50
83
  const raw = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
51
84
  const data = JSON.parse(raw);
52
- sessions.push(data.meta);
85
+ if (data?.meta?.id && data?.meta?.updatedAt) {
86
+ sessions.push(data.meta);
87
+ }
53
88
  }
54
89
  catch (e) {
55
90
  logger.warn('Failed to read session file', { file, error: e.message });
@@ -69,3 +104,62 @@ export async function deleteSession(id) {
69
104
  export function generateSessionId() {
70
105
  return randomUUID();
71
106
  }
107
+ const CRASH_FILE = path.join(SESSIONS_DIR, '.crash-recovery.json');
108
+ export async function saveCrashState(sessionId, messages, model, costState) {
109
+ try {
110
+ await ensureDir();
111
+ const data = {
112
+ sessionId,
113
+ messages,
114
+ model,
115
+ costState,
116
+ savedAt: new Date().toISOString(),
117
+ };
118
+ await fs.writeFile(CRASH_FILE, JSON.stringify(data), { mode: 0o600 });
119
+ }
120
+ catch { }
121
+ }
122
+ export async function loadCrashState() {
123
+ try {
124
+ const raw = await fs.readFile(CRASH_FILE, 'utf-8');
125
+ const data = JSON.parse(raw);
126
+ if (data?.sessionId && Array.isArray(data?.messages)) {
127
+ return data;
128
+ }
129
+ }
130
+ catch { }
131
+ return null;
132
+ }
133
+ export async function clearCrashState() {
134
+ try {
135
+ await fs.unlink(CRASH_FILE);
136
+ }
137
+ catch { }
138
+ }
139
+ export async function forkSession(sourceId, forkPoint) {
140
+ const source = await loadSession(sourceId);
141
+ const forkedMessages = source.messages.slice(0, forkPoint);
142
+ const newId = generateSessionId();
143
+ const meta = await saveSession(newId, forkedMessages, source.meta.model, `Fork of ${source.meta.name} (at msg ${forkPoint})`, source.costState);
144
+ return { id: newId, meta };
145
+ }
146
+ export async function getSessionDiff(id) {
147
+ const session = await loadSession(id);
148
+ const diffs = [];
149
+ for (const msg of session.messages) {
150
+ if (msg.role !== 'assistant' || !msg.tool_calls)
151
+ continue;
152
+ for (const tc of msg.tool_calls) {
153
+ if (tc.function.name === 'edit_file' || tc.function.name === 'write_file') {
154
+ try {
155
+ const args = JSON.parse(tc.function.arguments);
156
+ diffs.push(`${tc.function.name}: ${args.file_path || args.filePath || 'unknown'}`);
157
+ }
158
+ catch {
159
+ diffs.push(`${tc.function.name}: (parse error)`);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return diffs;
165
+ }
package/dist/settings.js CHANGED
@@ -1,9 +1,16 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, } from './constants.js';
4
- const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR);
5
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
3
+ import { setSecurityConfig, setScopedPermissionRules } from './sandbox.js';
4
+ 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, } from './constants.js';
5
+ import { getApiKeyFromKeychain, setApiKeyInKeychain, isKeychainAvailable } from './keychain.js';
6
+ function getConfigDir() {
7
+ return path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR);
8
+ }
9
+ function getConfigFile() {
10
+ return path.join(getConfigDir(), 'config.json');
11
+ }
6
12
  export const THINKING_MODES = ['off', 'low', 'medium', 'high'];
13
+ export const DEFAULT_SECURITY = {};
7
14
  export const DEFAULT_LIMITS = {
8
15
  maxSteps: AGENT_DEFAULTS.maxSteps,
9
16
  maxMessages: AGENT_DEFAULTS.maxMessages,
@@ -41,6 +48,9 @@ export const DEFAULT_LIMITS = {
41
48
  maxNotebookSource: TOOL_DEFAULTS.maxNotebookSource,
42
49
  maxWebFetchLength: TOOL_DEFAULTS.maxWebFetchLength,
43
50
  slidingWindowTarget: CONTEXT_DEFAULTS.slidingWindowTarget,
51
+ maxConcurrentAgents: MULTI_AGENT_DEFAULTS.maxConcurrentAgents,
52
+ maxAgentDepth: MULTI_AGENT_DEFAULTS.maxDepth,
53
+ globalTokenBudget: MULTI_AGENT_DEFAULTS.globalTokenBudget,
44
54
  };
45
55
  const DEFAULTS = {
46
56
  apiKey: '',
@@ -50,6 +60,9 @@ const DEFAULTS = {
50
60
  limits: { ...DEFAULT_LIMITS },
51
61
  permissionMode: 'default',
52
62
  thinkingMode: 'off',
63
+ security: { ...DEFAULT_SECURITY },
64
+ injectionDetection: { enabled: true, sensitivity: 'medium' },
65
+ disableUpdateCheck: false,
53
66
  };
54
67
  function safeNumber(val, min, max, fallback) {
55
68
  if (typeof val === 'number' && Number.isFinite(val) && val >= min && val <= max)
@@ -121,34 +134,207 @@ function validateConfig(parsed) {
121
134
  maxNotebookSource: safeNumber(l.maxNotebookSource, 1000, Infinity, DEFAULT_LIMITS.maxNotebookSource),
122
135
  maxWebFetchLength: safeNumber(l.maxWebFetchLength, 1000, Infinity, DEFAULT_LIMITS.maxWebFetchLength),
123
136
  slidingWindowTarget: safeNumber(l.slidingWindowTarget, 0.1, 0.99, DEFAULT_LIMITS.slidingWindowTarget),
137
+ maxConcurrentAgents: safeNumber(l.maxConcurrentAgents, 1, 100, DEFAULT_LIMITS.maxConcurrentAgents),
138
+ maxAgentDepth: safeNumber(l.maxAgentDepth, 1, 20, DEFAULT_LIMITS.maxAgentDepth),
139
+ globalTokenBudget: safeNumber(l.globalTokenBudget, 10_000, Infinity, DEFAULT_LIMITS.globalTokenBudget),
124
140
  };
125
141
  }
126
142
  if (typeof parsed.thinkingMode === 'string' && THINKING_MODES.includes(parsed.thinkingMode)) {
127
143
  safe.thinkingMode = parsed.thinkingMode;
128
144
  }
145
+ if (parsed.security && typeof parsed.security === 'object' && !Array.isArray(parsed.security)) {
146
+ safe.security = validateSecurityConfig(parsed.security);
147
+ }
148
+ if (parsed.injectionDetection && typeof parsed.injectionDetection === 'object') {
149
+ const id = parsed.injectionDetection;
150
+ const config = {
151
+ enabled: typeof id.enabled === 'boolean' ? id.enabled : true,
152
+ sensitivity: ['low', 'medium', 'high'].includes(id.sensitivity) ? id.sensitivity : 'medium',
153
+ };
154
+ if (isStringArray(id.customPatterns)) {
155
+ config.customPatterns = validateRegexStrings(id.customPatterns);
156
+ }
157
+ safe.injectionDetection = config;
158
+ }
159
+ if (typeof parsed.disableUpdateCheck === 'boolean') {
160
+ safe.disableUpdateCheck = parsed.disableUpdateCheck;
161
+ }
129
162
  return safe;
130
163
  }
164
+ function isStringArray(val) {
165
+ return Array.isArray(val) && val.every((v) => typeof v === 'string');
166
+ }
167
+ function validateRegexStrings(patterns) {
168
+ return patterns.filter(p => {
169
+ try {
170
+ new RegExp(p);
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ });
177
+ }
178
+ function validateSecurityConfig(raw) {
179
+ const sec = {};
180
+ if (isStringArray(raw.additionalBlockedCommands)) {
181
+ sec.additionalBlockedCommands = validateRegexStrings(raw.additionalBlockedCommands);
182
+ }
183
+ if (isStringArray(raw.disabledBlockedCommands)) {
184
+ sec.disabledBlockedCommands = raw.disabledBlockedCommands;
185
+ }
186
+ if (isStringArray(raw.additionalInjectionPatterns)) {
187
+ sec.additionalInjectionPatterns = validateRegexStrings(raw.additionalInjectionPatterns);
188
+ }
189
+ if (isStringArray(raw.disabledInjectionPatterns)) {
190
+ sec.disabledInjectionPatterns = raw.disabledInjectionPatterns;
191
+ }
192
+ if (isStringArray(raw.additionalReadOnlyTools))
193
+ sec.additionalReadOnlyTools = raw.additionalReadOnlyTools;
194
+ if (isStringArray(raw.additionalWriteTools))
195
+ sec.additionalWriteTools = raw.additionalWriteTools;
196
+ if (isStringArray(raw.additionalDangerousTools))
197
+ sec.additionalDangerousTools = raw.additionalDangerousTools;
198
+ if (raw.toolOverrides && typeof raw.toolOverrides === 'object' && !Array.isArray(raw.toolOverrides)) {
199
+ const valid = {};
200
+ const levels = new Set(['read-only', 'write', 'dangerous']);
201
+ for (const [tool, level] of Object.entries(raw.toolOverrides)) {
202
+ if (typeof level === 'string' && levels.has(level)) {
203
+ valid[tool] = level;
204
+ }
205
+ }
206
+ if (Object.keys(valid).length > 0)
207
+ sec.toolOverrides = valid;
208
+ }
209
+ if (typeof raw.allowPrivateNetworkAccess === 'boolean')
210
+ sec.allowPrivateNetworkAccess = raw.allowPrivateNetworkAccess;
211
+ if (isStringArray(raw.additionalBlockedHosts))
212
+ sec.additionalBlockedHosts = raw.additionalBlockedHosts;
213
+ if (isStringArray(raw.allowedPrivateHosts))
214
+ sec.allowedPrivateHosts = raw.allowedPrivateHosts;
215
+ if (isStringArray(raw.additionalSensitiveEnvPatterns)) {
216
+ sec.additionalSensitiveEnvPatterns = validateRegexStrings(raw.additionalSensitiveEnvPatterns);
217
+ }
218
+ if (isStringArray(raw.allowedEnvPatterns)) {
219
+ sec.allowedEnvPatterns = validateRegexStrings(raw.allowedEnvPatterns);
220
+ }
221
+ if (typeof raw.maxImageSize === 'number' && raw.maxImageSize > 0) {
222
+ sec.maxImageSize = raw.maxImageSize;
223
+ }
224
+ return sec;
225
+ }
131
226
  async function ensureDir() {
132
- await fs.mkdir(CONFIG_DIR, { recursive: true });
227
+ await fs.mkdir(getConfigDir(), { recursive: true });
228
+ }
229
+ export function applySecurityConfig(security) {
230
+ setSecurityConfig(security);
231
+ setNetworkSecurityOverrides({
232
+ allowAll: security.allowPrivateNetworkAccess,
233
+ blockedHosts: security.additionalBlockedHosts,
234
+ allowedHosts: security.allowedPrivateHosts,
235
+ });
236
+ }
237
+ async function loadLocalOverrides(config) {
238
+ const localPaths = [
239
+ path.join(process.cwd(), '.scalix', 'settings.local.json'),
240
+ path.join(process.cwd(), '.scalix-coder', 'settings.local.json'),
241
+ ];
242
+ for (const localPath of localPaths) {
243
+ try {
244
+ const raw = await fs.readFile(localPath, 'utf-8');
245
+ const overrides = JSON.parse(raw);
246
+ if (overrides.models)
247
+ config.models = overrides.models;
248
+ if (overrides.permissionMode)
249
+ config.permissionMode = overrides.permissionMode;
250
+ if (overrides.thinkingMode)
251
+ config.thinkingMode = overrides.thinkingMode;
252
+ if (overrides.limits)
253
+ config.limits = { ...config.limits, ...overrides.limits };
254
+ if (overrides.security)
255
+ config.security = { ...config.security, ...overrides.security };
256
+ if (overrides.scopedPermissions)
257
+ config.scopedPermissions = overrides.scopedPermissions;
258
+ if (overrides.customModels)
259
+ config.customModels = { ...config.customModels, ...overrides.customModels };
260
+ break;
261
+ }
262
+ catch { }
263
+ }
264
+ return config;
265
+ }
266
+ async function loadManagedSettings(config) {
267
+ const managedPaths = [
268
+ path.join(getConfigDir(), 'managed-settings.json'),
269
+ ];
270
+ for (const managedPath of managedPaths) {
271
+ try {
272
+ const raw = await fs.readFile(managedPath, 'utf-8');
273
+ const managed = JSON.parse(raw);
274
+ if (managed.permissionMode)
275
+ config.permissionMode = managed.permissionMode;
276
+ if (managed.security)
277
+ config.security = { ...config.security, ...managed.security };
278
+ if (managed.scopedPermissions)
279
+ config.scopedPermissions = [
280
+ ...(config.scopedPermissions || []),
281
+ ...managed.scopedPermissions,
282
+ ];
283
+ if (managed.disableUpdateCheck !== undefined)
284
+ config.disableUpdateCheck = managed.disableUpdateCheck;
285
+ break;
286
+ }
287
+ catch { }
288
+ }
289
+ return config;
133
290
  }
134
291
  export async function loadConfig() {
135
292
  try {
136
- const raw = await fs.readFile(CONFIG_FILE, 'utf-8');
293
+ const raw = await fs.readFile(getConfigFile(), 'utf-8');
137
294
  const parsed = JSON.parse(raw);
138
295
  const validated = validateConfig(parsed);
139
- const config = { ...DEFAULTS, ...validated };
296
+ let config = { ...DEFAULTS, ...validated };
297
+ // Try keychain for API key (preferred over plaintext config)
298
+ if (!config.apiKey || config.apiKey === '') {
299
+ const keychainKey = await getApiKeyFromKeychain().catch(() => null);
300
+ if (keychainKey)
301
+ config.apiKey = keychainKey;
302
+ }
303
+ // Apply local overrides (project-level, gitignored)
304
+ config = await loadLocalOverrides(config);
305
+ // Apply managed/organization settings (enterprise policy)
306
+ config = await loadManagedSettings(config);
140
307
  if (config.customModels && Object.keys(config.customModels).length > 0) {
141
308
  registerUserModels(config.customModels);
142
309
  }
310
+ if (config.security && Object.keys(config.security).length > 0) {
311
+ applySecurityConfig(config.security);
312
+ }
313
+ if (config.scopedPermissions && config.scopedPermissions.length > 0) {
314
+ setScopedPermissionRules(config.scopedPermissions);
315
+ }
143
316
  return config;
144
317
  }
145
318
  catch {
146
- return { ...DEFAULTS };
319
+ // Even if config file fails, try keychain
320
+ const config = { ...DEFAULTS };
321
+ const keychainKey = await getApiKeyFromKeychain().catch(() => null);
322
+ if (keychainKey)
323
+ config.apiKey = keychainKey;
324
+ return config;
147
325
  }
148
326
  }
149
327
  export async function saveConfig(config) {
150
328
  await ensureDir();
151
- await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
329
+ // Store API key in keychain if available, strip from config file
330
+ const configToWrite = { ...config };
331
+ if (config.apiKey && await isKeychainAvailable()) {
332
+ const stored = await setApiKeyInKeychain(config.apiKey).catch(() => false);
333
+ if (stored) {
334
+ configToWrite.apiKey = '';
335
+ }
336
+ }
337
+ await fs.writeFile(getConfigFile(), JSON.stringify(configToWrite, null, 2), { encoding: 'utf-8', mode: 0o600 });
152
338
  }
153
339
  export function isConfigured(config) {
154
340
  return config.apiKey.length > 0 && config.models.length > 0;