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/.env.example +29 -0
- package/PARITY-PLAN.md +116 -0
- package/README.md +1 -1
- package/bin/scalix.js +1 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent.js +363 -74
- package/dist/api-client.js +77 -0
- package/dist/cli.js +724 -76
- package/dist/commands.js +53 -2
- package/dist/constants.js +43 -9
- package/dist/context-builder.js +8 -1
- package/dist/context-retriever.js +257 -0
- package/dist/git-context.js +32 -29
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +149 -20
- package/dist/hooks.js +148 -10
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +6 -2
- package/dist/keychain.js +109 -0
- package/dist/markdown.js +46 -3
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +22 -1
- package/dist/sandbox.js +201 -11
- package/dist/sessions.js +96 -2
- package/dist/settings.js +194 -8
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +83 -8
- package/dist/task-board.js +105 -0
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/tools-advanced.js +304 -49
- package/dist/tools-browser.js +280 -19
- package/dist/tools-extra.js +9 -8
- package/dist/tools-vision.js +4 -3
- package/dist/tools.js +13 -3
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/package.json +8 -3
- package/app.py +0 -55
package/dist/sandbox.js
CHANGED
|
@@ -45,7 +45,75 @@ export async function sandboxPathAsync(filePath) {
|
|
|
45
45
|
throw err;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
|
191
|
+
return sets.readOnly.has(toolName) ? 'allow' : 'block';
|
|
79
192
|
case 'default':
|
|
80
|
-
if (
|
|
193
|
+
if (sets.readOnly.has(toolName))
|
|
81
194
|
return 'allow';
|
|
82
195
|
return 'ask';
|
|
83
196
|
case 'auto':
|
|
84
|
-
if (
|
|
197
|
+
if (sets.readOnly.has(toolName))
|
|
85
198
|
return 'allow';
|
|
86
|
-
if (
|
|
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
|
-
|
|
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
|
|
433
|
+
return getEffectiveBlocklist().some((re) => re.test(command));
|
|
260
434
|
}
|
|
261
435
|
export function detectInjection(command) {
|
|
262
|
-
for (const pattern of
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
4
|
-
|
|
5
|
-
|
|
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(
|
|
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(
|
|
293
|
+
const raw = await fs.readFile(getConfigFile(), 'utf-8');
|
|
137
294
|
const parsed = JSON.parse(raw);
|
|
138
295
|
const validated = validateConfig(parsed);
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|