scalix 1.2.0 → 1.4.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/CHANGELOG.md +27 -0
- package/README.md +13 -2
- package/bin/scalix.js +8 -0
- package/dist/agent.js +148 -32
- package/dist/api-client.js +49 -11
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +582 -173
- package/dist/cloud.js +236 -0
- package/dist/commands.js +19 -2
- package/dist/constants.js +138 -11
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +102 -45
- package/dist/hooks.js +55 -5
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +117 -4
- package/dist/markdown.js +4 -1
- package/dist/model-router.js +11 -0
- package/dist/sandbox.js +93 -10
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +6 -6
- package/dist/settings.js +53 -14
- package/dist/sub-agent.js +59 -2
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +42 -6
- package/dist/tools-voice.js +9 -15
- package/dist/tools-web.js +81 -54
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
package/dist/headless.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
3
|
import { execSync } from 'node:child_process';
|
|
5
4
|
import { createAgent } from './agent.js';
|
|
6
5
|
import { defaultTools } from './tools.js';
|
|
@@ -23,6 +22,7 @@ import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote
|
|
|
23
22
|
import { buildProjectIndex } from './project-index.js';
|
|
24
23
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
25
24
|
import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
|
|
25
|
+
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
26
26
|
import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
|
|
27
27
|
import { hooksSummary } from './hooks.js';
|
|
28
28
|
import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
|
|
@@ -102,6 +102,7 @@ Options:
|
|
|
102
102
|
--output-format Alias for --format
|
|
103
103
|
--output-schema Path to JSON schema file (implies --format json-schema)
|
|
104
104
|
--worktree, -w Run in an isolated git worktree (optional: --worktree <name>)
|
|
105
|
+
--version, -v Print the installed version
|
|
105
106
|
--help, -h Show this help
|
|
106
107
|
`);
|
|
107
108
|
process.exit(0);
|
|
@@ -131,7 +132,7 @@ async function buildEnhancedInstructions() {
|
|
|
131
132
|
const ctx = await buildProjectContext(cwd);
|
|
132
133
|
return contextToInstructions(base, ctx);
|
|
133
134
|
}
|
|
134
|
-
async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
135
|
+
async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
|
|
135
136
|
const apiKey = process.env[ENV_API_KEY];
|
|
136
137
|
if (!apiKey) {
|
|
137
138
|
console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
|
|
@@ -159,7 +160,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
|
159
160
|
process.exit(1);
|
|
160
161
|
}
|
|
161
162
|
const mcpTools = loadMcpServices();
|
|
162
|
-
|
|
163
|
+
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
164
|
+
// dynamically import and execute code from the project directory.
|
|
165
|
+
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
166
|
+
if (!isWorkspaceTrusted()) {
|
|
167
|
+
process.stderr.write('Project plugins skipped (untrusted workspace).\n');
|
|
168
|
+
}
|
|
163
169
|
const pluginTools = getPluginTools(plugins);
|
|
164
170
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
165
171
|
const instructions = await buildEnhancedInstructions();
|
|
@@ -201,17 +207,29 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
|
201
207
|
groundingCheck: true,
|
|
202
208
|
contextRetrieval: true,
|
|
203
209
|
});
|
|
210
|
+
// Register the account's Scalix Cloud platform tools (one attempt; failure
|
|
211
|
+
// is stated once and the run continues with local tools only).
|
|
212
|
+
const cloudStatus = await agent.refreshCloudTools();
|
|
213
|
+
if (cloudStatus.state === 'unavailable') {
|
|
214
|
+
process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m\n`);
|
|
215
|
+
}
|
|
204
216
|
let fullResponse = '';
|
|
205
|
-
agent.on('stream:end', (text) => { fullResponse = text; });
|
|
206
217
|
agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
|
|
207
218
|
agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
|
|
208
219
|
agent.on('verify:fail', (r) => process.stderr.write(`\x1b[31m✗ ${r.checker} failed: ${r.output.split('\n')[0]}\x1b[0m\n`));
|
|
209
220
|
agent.on('error', (err) => process.stderr.write(`\x1b[31mError: ${err.message}\x1b[0m\n`));
|
|
221
|
+
agent.on('usage', (data) => {
|
|
222
|
+
costTracker.record(data.model || selectedModel[0], data);
|
|
223
|
+
});
|
|
210
224
|
try {
|
|
211
|
-
|
|
225
|
+
// Use the return value (applies JSON extraction for --format json/json-schema)
|
|
226
|
+
// rather than the raw stream text.
|
|
227
|
+
fullResponse = await agent.send(prompt);
|
|
212
228
|
}
|
|
213
229
|
catch (err) {
|
|
214
230
|
console.error(`Error: ${err.message}`);
|
|
231
|
+
if (beforeExit)
|
|
232
|
+
await beforeExit();
|
|
215
233
|
process.exit(1);
|
|
216
234
|
}
|
|
217
235
|
let formatted;
|
|
@@ -245,9 +263,11 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
|
245
263
|
process.stdout.write(formatted + '\n');
|
|
246
264
|
}
|
|
247
265
|
await closeBrowser();
|
|
266
|
+
if (beforeExit)
|
|
267
|
+
await beforeExit();
|
|
248
268
|
process.exit(0);
|
|
249
269
|
}
|
|
250
|
-
async function runPipeMode() {
|
|
270
|
+
async function runPipeMode(beforeExit) {
|
|
251
271
|
const MAX_PIPE_BYTES = 50 * 1024 * 1024;
|
|
252
272
|
const chunks = [];
|
|
253
273
|
let totalBytes = 0;
|
|
@@ -257,6 +277,8 @@ async function runPipeMode() {
|
|
|
257
277
|
totalBytes += Buffer.byteLength(str);
|
|
258
278
|
if (totalBytes > MAX_PIPE_BYTES) {
|
|
259
279
|
console.error(`Error: Pipe input exceeds ${MAX_PIPE_BYTES / 1024 / 1024}MB limit.`);
|
|
280
|
+
if (beforeExit)
|
|
281
|
+
await beforeExit();
|
|
260
282
|
process.exit(1);
|
|
261
283
|
}
|
|
262
284
|
chunks.push(str);
|
|
@@ -264,14 +286,18 @@ async function runPipeMode() {
|
|
|
264
286
|
}
|
|
265
287
|
catch (err) {
|
|
266
288
|
console.error(`Error reading pipe input: ${err.message}`);
|
|
289
|
+
if (beforeExit)
|
|
290
|
+
await beforeExit();
|
|
267
291
|
process.exit(1);
|
|
268
292
|
}
|
|
269
293
|
const prompt = chunks.join('').trim();
|
|
270
294
|
if (!prompt) {
|
|
271
295
|
console.error('Error: No input received from stdin.');
|
|
296
|
+
if (beforeExit)
|
|
297
|
+
await beforeExit();
|
|
272
298
|
process.exit(1);
|
|
273
299
|
}
|
|
274
|
-
await runSinglePrompt(prompt);
|
|
300
|
+
await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
|
|
275
301
|
}
|
|
276
302
|
async function runInteractive() {
|
|
277
303
|
const apiKey = process.env[ENV_API_KEY];
|
|
@@ -292,7 +318,12 @@ async function runInteractive() {
|
|
|
292
318
|
throw new Error('No models configured. Set SCALIX_MODEL env var or run interactive setup.');
|
|
293
319
|
}
|
|
294
320
|
const mcpTools = loadMcpServices();
|
|
295
|
-
|
|
321
|
+
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
322
|
+
// dynamically import and execute code from the project directory.
|
|
323
|
+
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
324
|
+
if (!isWorkspaceTrusted()) {
|
|
325
|
+
process.stderr.write('Project plugins skipped (untrusted workspace).\n');
|
|
326
|
+
}
|
|
296
327
|
const pluginTools = getPluginTools(plugins);
|
|
297
328
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
298
329
|
const instructions = await buildEnhancedInstructions();
|
|
@@ -367,27 +398,41 @@ async function runInteractive() {
|
|
|
367
398
|
console.log(`\x1b[33m\u26A0 Context usage: ${info.percentage}% (${info.tokens}/${info.maxTokens} tokens)\x1b[0m`);
|
|
368
399
|
}
|
|
369
400
|
});
|
|
370
|
-
|
|
401
|
+
// Register the account's Scalix Cloud platform tools (one attempt; failure
|
|
402
|
+
// is stated once and the session continues with local tools only).
|
|
403
|
+
const cloudStatus = await agent.refreshCloudTools();
|
|
404
|
+
if (cloudStatus.state === 'unavailable') {
|
|
405
|
+
console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m`);
|
|
406
|
+
}
|
|
407
|
+
const toolCount = allTools.length + cloudStatus.toolCount;
|
|
371
408
|
const modelDisplay = selectedModel.map(shortModel).join(' | ');
|
|
372
|
-
|
|
409
|
+
const cloudSuffix = cloudStatus.state === 'connected' ? ` (${cloudStatus.toolCount} from Scalix Cloud)` : '';
|
|
410
|
+
console.log(`\x1b[36mAgent ready\x1b[0m (${modelDisplay}) with ${toolCount} tools${cloudSuffix}. Type /help for commands.`);
|
|
373
411
|
if (plugins.length > 0) {
|
|
374
412
|
console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
|
|
375
413
|
}
|
|
376
414
|
console.log('');
|
|
377
|
-
|
|
415
|
+
let shuttingDown = false;
|
|
416
|
+
async function shutdown() {
|
|
417
|
+
if (shuttingDown)
|
|
418
|
+
return;
|
|
419
|
+
shuttingDown = true;
|
|
378
420
|
const msgs = agent.getMessages();
|
|
379
421
|
if (msgs.length > 0) {
|
|
380
|
-
|
|
422
|
+
// Await the save so sessions aren't lost on SIGINT (process.exit would
|
|
423
|
+
// otherwise cut off the fire-and-forget write).
|
|
424
|
+
await saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
|
|
381
425
|
}
|
|
382
426
|
if (costTracker.totalTokens > 0) {
|
|
383
427
|
console.log(`\n\x1b[36mSession: ${costTracker.totalTokens.toLocaleString()} tokens${costTracker.totalCost > 0 ? ` ($${costTracker.totalCost.toFixed(4)})` : ''}\x1b[0m`);
|
|
384
428
|
}
|
|
385
429
|
console.log('Goodbye!');
|
|
386
430
|
rl.close();
|
|
387
|
-
closeBrowser().
|
|
431
|
+
await closeBrowser().catch(() => { });
|
|
432
|
+
process.exit(0);
|
|
388
433
|
}
|
|
389
|
-
process.on('SIGINT', shutdown);
|
|
390
|
-
process.on('SIGTERM', shutdown);
|
|
434
|
+
process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
|
|
435
|
+
process.on('SIGTERM', () => { shutdown().catch(() => process.exit(1)); });
|
|
391
436
|
const handleCommand = async (input) => {
|
|
392
437
|
const cmd = input.split(' ')[0].toLowerCase();
|
|
393
438
|
if (cmd === '/help') {
|
|
@@ -656,7 +701,7 @@ async function runInteractive() {
|
|
|
656
701
|
return true;
|
|
657
702
|
}
|
|
658
703
|
if (cmd === '/quit' || cmd === '/exit') {
|
|
659
|
-
shutdown();
|
|
704
|
+
await shutdown();
|
|
660
705
|
return true;
|
|
661
706
|
}
|
|
662
707
|
const customCmd = customCmds.find((c) => cmd === `/${c.name}`);
|
|
@@ -730,11 +775,15 @@ async function main() {
|
|
|
730
775
|
}
|
|
731
776
|
// Set up worktree isolation if requested
|
|
732
777
|
let worktreeInfo = null;
|
|
778
|
+
// Capture the original cwd BEFORE any process.chdir so cleanup can restore it
|
|
779
|
+
// and resolve the worktree parent correctly.
|
|
780
|
+
const originalCwd = process.cwd();
|
|
781
|
+
let cleanup = async () => { };
|
|
733
782
|
if (worktree) {
|
|
734
783
|
const { createWorktree, removeWorktree } = await import('./worktree.js');
|
|
735
784
|
const name = worktreeName || `session-${Date.now().toString(36)}`;
|
|
736
785
|
try {
|
|
737
|
-
worktreeInfo = await createWorktree(name,
|
|
786
|
+
worktreeInfo = await createWorktree(name, originalCwd);
|
|
738
787
|
process.chdir(worktreeInfo.path);
|
|
739
788
|
setSandboxRoot(worktreeInfo.path);
|
|
740
789
|
console.log(`Working in isolated worktree: ${worktreeInfo.branch} → ${worktreeInfo.path}`);
|
|
@@ -743,38 +792,46 @@ async function main() {
|
|
|
743
792
|
console.error(`Failed to create worktree: ${err.message}`);
|
|
744
793
|
process.exit(1);
|
|
745
794
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
795
|
+
let cleanedUp = false;
|
|
796
|
+
cleanup = async () => {
|
|
797
|
+
if (cleanedUp || !worktreeInfo)
|
|
798
|
+
return;
|
|
799
|
+
cleanedUp = true;
|
|
800
|
+
try {
|
|
801
|
+
// Return to the original directory before removing the worktree so we
|
|
802
|
+
// are not standing inside the directory being deleted.
|
|
803
|
+
process.chdir(originalCwd);
|
|
804
|
+
await removeWorktree(worktreeInfo, originalCwd);
|
|
754
805
|
}
|
|
806
|
+
catch { }
|
|
755
807
|
};
|
|
756
|
-
|
|
808
|
+
// 'exit' handlers cannot run async work — SIGINT awaits cleanup explicitly.
|
|
757
809
|
process.on('SIGINT', () => { cleanup().then(() => process.exit(0)).catch(() => process.exit(1)); });
|
|
758
|
-
process.env.SCALIX_ORIGINAL_CWD = process.cwd();
|
|
759
810
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
811
|
+
try {
|
|
812
|
+
switch (mode) {
|
|
813
|
+
case 'run':
|
|
814
|
+
if (!prompt) {
|
|
815
|
+
console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
|
|
816
|
+
await cleanup();
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
await runSinglePrompt(prompt, output, format, outputSchema, cleanup);
|
|
820
|
+
break;
|
|
821
|
+
case 'pipe':
|
|
822
|
+
await runPipeMode(cleanup);
|
|
823
|
+
break;
|
|
824
|
+
case 'ipc':
|
|
825
|
+
await runIpcServer();
|
|
826
|
+
break;
|
|
827
|
+
case 'interactive':
|
|
828
|
+
default:
|
|
829
|
+
await runInteractive();
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
await cleanup();
|
|
778
835
|
}
|
|
779
836
|
}
|
|
780
837
|
main().catch((err) => {
|
package/dist/hooks.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
2
|
+
import { resolve, join, basename } from 'node:path';
|
|
3
3
|
import { exec } from 'node:child_process';
|
|
4
|
-
import { promisify } from 'node:util';
|
|
5
4
|
import { logger } from './logger.js';
|
|
6
5
|
import { PROJECT_LOOKUP_DIRS, HOOK_DEFAULTS } from './constants.js';
|
|
7
|
-
|
|
6
|
+
import { sanitizeEnvForCommand } from './sandbox.js';
|
|
7
|
+
// Hook commands are written in POSIX sh syntax ($VAR, pipes, etc). On Windows
|
|
8
|
+
// the default exec shell is cmd.exe, which silently mangles them — so resolve
|
|
9
|
+
// a real POSIX shell (Git Bash) when one is available. `null` = not resolved.
|
|
10
|
+
let cachedHookShell = null;
|
|
11
|
+
function isPosixShell(shellPath) {
|
|
12
|
+
const name = basename(shellPath).toLowerCase().replace(/\.exe$/, '');
|
|
13
|
+
return name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash';
|
|
14
|
+
}
|
|
15
|
+
function resolveHookShell() {
|
|
16
|
+
if (cachedHookShell !== null)
|
|
17
|
+
return cachedHookShell;
|
|
18
|
+
if (process.platform !== 'win32') {
|
|
19
|
+
cachedHookShell = undefined;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const candidates = [
|
|
23
|
+
process.env.SCALIX_HOOK_SHELL,
|
|
24
|
+
process.env.SHELL,
|
|
25
|
+
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
|
|
26
|
+
join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
|
|
27
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') : undefined,
|
|
28
|
+
];
|
|
29
|
+
for (const c of candidates) {
|
|
30
|
+
// Skip WSL/System32 bash implicitly: only explicit paths are checked, and
|
|
31
|
+
// non-POSIX shells (e.g. a SHELL pointing at PowerShell) are rejected.
|
|
32
|
+
if (c && isPosixShell(c) && existsSync(c)) {
|
|
33
|
+
cachedHookShell = c;
|
|
34
|
+
return c;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
cachedHookShell = undefined;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
8
40
|
const HOOK_PATHS = PROJECT_LOOKUP_DIRS.map(d => `${d}/hooks.json`);
|
|
9
41
|
export function loadHooks(cwd = process.cwd()) {
|
|
10
42
|
for (const relPath of HOOK_PATHS) {
|
|
@@ -112,17 +144,24 @@ function parseHookOutput(stdout, exitCode) {
|
|
|
112
144
|
}
|
|
113
145
|
export async function executeHook(hook, env, stdinData) {
|
|
114
146
|
const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
|
|
115
|
-
|
|
147
|
+
// Strip secrets from the inherited environment so hook commands can't read
|
|
148
|
+
// API keys, tokens, cloud credentials, etc. The SCALIX_* context vars in
|
|
149
|
+
// `env` are still applied on top.
|
|
150
|
+
const hookEnv = { ...sanitizeEnvForCommand(process.env), ...env };
|
|
116
151
|
const ac = new AbortController();
|
|
117
152
|
const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
|
|
118
153
|
try {
|
|
154
|
+
const shell = resolveHookShell();
|
|
119
155
|
const child = exec(hook.command, {
|
|
120
156
|
timeout,
|
|
121
157
|
env: hookEnv,
|
|
122
158
|
maxBuffer: HOOK_DEFAULTS.maxBuffer,
|
|
123
159
|
signal: ac.signal,
|
|
160
|
+
...(shell ? { shell } : {}),
|
|
124
161
|
});
|
|
125
162
|
if (stdinData && child.stdin) {
|
|
163
|
+
// The hook may exit without reading stdin; swallow the resulting EPIPE.
|
|
164
|
+
child.stdin.on('error', () => { });
|
|
126
165
|
child.stdin.write(stdinData);
|
|
127
166
|
child.stdin.end();
|
|
128
167
|
}
|
|
@@ -132,8 +171,19 @@ export async function executeHook(hook, env, stdinData) {
|
|
|
132
171
|
child.stdout?.on('data', (d) => { stdout += d; });
|
|
133
172
|
child.stderr?.on('data', (d) => { stderr += d; });
|
|
134
173
|
child.on('error', reject);
|
|
135
|
-
child.on('close', (code) => resolve({
|
|
174
|
+
child.on('close', (code, signal) => resolve({
|
|
175
|
+
stdout,
|
|
176
|
+
stderr,
|
|
177
|
+
code: code ?? 0,
|
|
178
|
+
// A killed child reports code=null + a signal (or child.killed on
|
|
179
|
+
// Windows). We only ever kill on timeout/abort, so treat it as such —
|
|
180
|
+
// otherwise `code ?? 0` would misreport the hook as allowed.
|
|
181
|
+
killed: child.killed || signal !== null,
|
|
182
|
+
}));
|
|
136
183
|
});
|
|
184
|
+
if (result.killed) {
|
|
185
|
+
return { allowed: false, error: `Hook timed out after ${timeout}ms` };
|
|
186
|
+
}
|
|
137
187
|
return parseHookOutput(result.stdout, result.code);
|
|
138
188
|
}
|
|
139
189
|
catch (err) {
|
|
@@ -38,7 +38,24 @@ const SENSITIVITY_THRESHOLDS = {
|
|
|
38
38
|
medium: { highConfidence: 3, mediumConfidence: 2 },
|
|
39
39
|
high: { highConfidence: 2, mediumConfidence: 1 },
|
|
40
40
|
};
|
|
41
|
-
|
|
41
|
+
// Strip zero-width and other invisible/formatting characters that are used to
|
|
42
|
+
// smuggle instructions past pattern matching (zero-width space/joiner, BOM,
|
|
43
|
+
// word joiner, bidi controls, soft hyphen, etc.).
|
|
44
|
+
const INVISIBLE_CHARS_RE = new RegExp('[' +
|
|
45
|
+
'\\u00AD' + // soft hyphen
|
|
46
|
+
'\\u200B-\\u200F' + // zero-width space/non-joiner/joiner, LRM/RLM
|
|
47
|
+
'\\u202A-\\u202E' + // bidi embedding/override controls
|
|
48
|
+
'\\u2060-\\u2064' + // word joiner, invisible operators
|
|
49
|
+
'\\u206A-\\u206F' + // deprecated format controls
|
|
50
|
+
'\\uFEFF' + // BOM / zero-width no-break space
|
|
51
|
+
']', 'g');
|
|
52
|
+
function normalizeForDetection(text) {
|
|
53
|
+
// NFKC folds homoglyph/compatibility variants (e.g. fullwidth chars) to their
|
|
54
|
+
// canonical forms so evasions collapse onto the same patterns.
|
|
55
|
+
return text.normalize('NFKC').replace(INVISIBLE_CHARS_RE, '');
|
|
56
|
+
}
|
|
57
|
+
export function detectPromptInjection(rawContent, source, sensitivity = 'medium', customPatterns) {
|
|
58
|
+
const content = normalizeForDetection(rawContent);
|
|
42
59
|
const matched = [];
|
|
43
60
|
let totalWeight = 0;
|
|
44
61
|
for (const rule of ALL_PATTERNS) {
|
package/dist/ipc-server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { browserTools, closeBrowser } from './tools-browser.js';
|
|
|
21
21
|
import { loadMcpServices } from './mcp.js';
|
|
22
22
|
import { loadConfig, DEFAULT_LIMITS } from './settings.js';
|
|
23
23
|
import { discoverPlugins, getPluginTools } from './plugins.js';
|
|
24
|
+
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
24
25
|
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
|
|
25
26
|
import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
|
|
26
27
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
@@ -68,7 +69,12 @@ export async function runIpcServer() {
|
|
|
68
69
|
const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
|
|
69
70
|
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
70
71
|
const mcpTools = loadMcpServices();
|
|
71
|
-
|
|
72
|
+
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
73
|
+
// dynamically import and execute code from the project directory.
|
|
74
|
+
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
75
|
+
if (!isWorkspaceTrusted()) {
|
|
76
|
+
process.stderr.write('IPC: project plugins skipped (untrusted workspace)\n');
|
|
77
|
+
}
|
|
72
78
|
const pluginTools = getPluginTools(plugins);
|
|
73
79
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
74
80
|
const instructions = await buildInstructions();
|
package/dist/keychain.js
CHANGED
|
@@ -1,11 +1,101 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { USER_CONFIG_DIR } from './constants.js';
|
|
3
7
|
const exec = promisify(execFile);
|
|
4
8
|
const SERVICE = 'scalix-coder';
|
|
5
9
|
const ACCOUNT = 'api-key';
|
|
10
|
+
/**
|
|
11
|
+
* SCALIX_NO_KEYCHAIN=1 disables OS keychain integration entirely — the API
|
|
12
|
+
* key stays in the config file. Intended for CI, headless environments, and
|
|
13
|
+
* hermetic tests where spawning security/secret-tool/PowerShell is unwanted.
|
|
14
|
+
*/
|
|
15
|
+
function keychainDisabled() {
|
|
16
|
+
const flag = process.env.SCALIX_NO_KEYCHAIN;
|
|
17
|
+
return flag === '1' || flag?.toLowerCase() === 'true';
|
|
18
|
+
}
|
|
19
|
+
function isWindows() {
|
|
20
|
+
return process.platform === 'win32';
|
|
21
|
+
}
|
|
6
22
|
async function isMacOS() {
|
|
7
23
|
return process.platform === 'darwin';
|
|
8
24
|
}
|
|
25
|
+
// ─── Windows DPAPI (user-scoped) via PowerShell ───
|
|
26
|
+
function windowsCredentialPath() {
|
|
27
|
+
// Resolve the home directory the same way the rest of the codebase does
|
|
28
|
+
// (settings/sessions/workspace-trust) so the credential store stays
|
|
29
|
+
// consistent and honors HOME (set by Git Bash and by tests).
|
|
30
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, 'credential.dat');
|
|
31
|
+
}
|
|
32
|
+
async function runPowerShell(script, stdinData) {
|
|
33
|
+
const psExe = process.env.SystemRoot
|
|
34
|
+
? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
35
|
+
: 'powershell.exe';
|
|
36
|
+
const child = exec(psExe, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
maxBuffer: 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
if (stdinData !== undefined) {
|
|
41
|
+
const stdin = child.child?.stdin;
|
|
42
|
+
if (!stdin)
|
|
43
|
+
throw new Error('PowerShell stdin unavailable');
|
|
44
|
+
stdin.write(stdinData);
|
|
45
|
+
stdin.end();
|
|
46
|
+
}
|
|
47
|
+
const { stdout } = await child;
|
|
48
|
+
return stdout;
|
|
49
|
+
}
|
|
50
|
+
async function setApiKeyWindows(apiKey) {
|
|
51
|
+
try {
|
|
52
|
+
const credPath = windowsCredentialPath();
|
|
53
|
+
await fs.mkdir(path.dirname(credPath), { recursive: true });
|
|
54
|
+
// Read the secret from stdin, encrypt it with user-scoped DPAPI, and print
|
|
55
|
+
// the encrypted string. Reading from stdin avoids putting the secret on the
|
|
56
|
+
// command line (where it could be seen in the process table).
|
|
57
|
+
const script = '$s = [Console]::In.ReadToEnd();' +
|
|
58
|
+
'$sec = ConvertTo-SecureString -String $s -AsPlainText -Force;' +
|
|
59
|
+
'[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))';
|
|
60
|
+
const encrypted = (await runPowerShell(script, apiKey)).trim();
|
|
61
|
+
if (!encrypted)
|
|
62
|
+
return false;
|
|
63
|
+
await fs.writeFile(credPath, encrypted, { encoding: 'utf-8', mode: 0o600 });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function getApiKeyWindows() {
|
|
71
|
+
try {
|
|
72
|
+
const credPath = windowsCredentialPath();
|
|
73
|
+
const encrypted = (await fs.readFile(credPath, 'utf-8')).trim();
|
|
74
|
+
if (!encrypted)
|
|
75
|
+
return null;
|
|
76
|
+
// Decrypt the DPAPI blob back to plaintext via Marshal. Pass the blob over
|
|
77
|
+
// stdin to avoid quoting/length issues on the command line.
|
|
78
|
+
const script = '$enc = [Console]::In.ReadToEnd().Trim();' +
|
|
79
|
+
'$sec = ConvertTo-SecureString -String $enc;' +
|
|
80
|
+
'$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec);' +
|
|
81
|
+
'try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }' +
|
|
82
|
+
'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }';
|
|
83
|
+
const plaintext = await runPowerShell(script, encrypted);
|
|
84
|
+
return plaintext.trim() || null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function deleteApiKeyWindows() {
|
|
91
|
+
try {
|
|
92
|
+
await fs.unlink(windowsCredentialPath());
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
9
99
|
async function isLinuxWithSecretTool() {
|
|
10
100
|
if (process.platform !== 'linux')
|
|
11
101
|
return false;
|
|
@@ -18,7 +108,12 @@ async function isLinuxWithSecretTool() {
|
|
|
18
108
|
}
|
|
19
109
|
}
|
|
20
110
|
export async function getApiKeyFromKeychain() {
|
|
111
|
+
if (keychainDisabled())
|
|
112
|
+
return null;
|
|
21
113
|
try {
|
|
114
|
+
if (isWindows()) {
|
|
115
|
+
return await getApiKeyWindows();
|
|
116
|
+
}
|
|
22
117
|
if (await isMacOS()) {
|
|
23
118
|
const { stdout } = await exec('security', [
|
|
24
119
|
'find-generic-password',
|
|
@@ -41,7 +136,12 @@ export async function getApiKeyFromKeychain() {
|
|
|
41
136
|
return null;
|
|
42
137
|
}
|
|
43
138
|
export async function setApiKeyInKeychain(apiKey) {
|
|
139
|
+
if (keychainDisabled())
|
|
140
|
+
return false;
|
|
44
141
|
try {
|
|
142
|
+
if (isWindows()) {
|
|
143
|
+
return await setApiKeyWindows(apiKey);
|
|
144
|
+
}
|
|
45
145
|
if (await isMacOS()) {
|
|
46
146
|
// Delete existing entry first (ignore errors if it doesn't exist)
|
|
47
147
|
try {
|
|
@@ -70,9 +170,15 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
70
170
|
'service', SERVICE,
|
|
71
171
|
'account', ACCOUNT,
|
|
72
172
|
]);
|
|
73
|
-
// secret-tool reads the secret from stdin
|
|
74
|
-
|
|
75
|
-
|
|
173
|
+
// secret-tool reads the secret from stdin. If stdin isn't available we
|
|
174
|
+
// cannot deliver the secret — treat that as a failure rather than
|
|
175
|
+
// silently reporting success (which would lose the key entirely).
|
|
176
|
+
const stdin = child.child?.stdin;
|
|
177
|
+
if (!stdin) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
stdin.write(apiKey);
|
|
181
|
+
stdin.end();
|
|
76
182
|
await child;
|
|
77
183
|
return true;
|
|
78
184
|
}
|
|
@@ -83,7 +189,12 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
83
189
|
return false;
|
|
84
190
|
}
|
|
85
191
|
export async function deleteApiKeyFromKeychain() {
|
|
192
|
+
if (keychainDisabled())
|
|
193
|
+
return false;
|
|
86
194
|
try {
|
|
195
|
+
if (isWindows()) {
|
|
196
|
+
return await deleteApiKeyWindows();
|
|
197
|
+
}
|
|
87
198
|
if (await isMacOS()) {
|
|
88
199
|
await exec('security', [
|
|
89
200
|
'delete-generic-password',
|
|
@@ -105,5 +216,7 @@ export async function deleteApiKeyFromKeychain() {
|
|
|
105
216
|
return false;
|
|
106
217
|
}
|
|
107
218
|
export async function isKeychainAvailable() {
|
|
108
|
-
|
|
219
|
+
if (keychainDisabled())
|
|
220
|
+
return false;
|
|
221
|
+
return isWindows() || await isMacOS() || await isLinuxWithSecretTool();
|
|
109
222
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { Marked } from 'marked';
|
|
3
3
|
import { markedTerminal } from 'marked-terminal';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
// This package is ESM ("type":"module"), so the CommonJS `require` global is
|
|
6
|
+
// not defined. Build one bound to this module's URL so cli-highlight loads.
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
4
8
|
let highlightFn = null;
|
|
5
9
|
let highlightLoaded = false;
|
|
6
10
|
function getHighlighter() {
|
|
@@ -9,7 +13,6 @@ function getHighlighter() {
|
|
|
9
13
|
highlightLoaded = true;
|
|
10
14
|
try {
|
|
11
15
|
// cli-highlight is a dependency — provides 100+ language highlighting
|
|
12
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
16
|
const mod = require('cli-highlight');
|
|
14
17
|
highlightFn = mod.highlight;
|
|
15
18
|
}
|
package/dist/model-router.js
CHANGED
|
@@ -19,6 +19,17 @@ export function classifyComplexity(query, toolCallCount = 0, config) {
|
|
|
19
19
|
return 'complex';
|
|
20
20
|
return 'moderate';
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Complexity-based model selection.
|
|
24
|
+
*
|
|
25
|
+
* Intended use: a caller that owns a {@link RouterConfig} (see
|
|
26
|
+
* `createRouterConfig`/`getRouterStatus`, wired from cli.tsx) can call this per
|
|
27
|
+
* user turn to pick a cheaper/stronger model for the request. It is
|
|
28
|
+
* deliberately conservative — it only diverges from the caller's default when
|
|
29
|
+
* the router config is `enabled` (which `createRouterConfig` defaults to
|
|
30
|
+
* `false`), so importing/calling it does NOT change default behavior. When
|
|
31
|
+
* disabled, or with no tiers, it always returns `config.defaultModel`.
|
|
32
|
+
*/
|
|
22
33
|
export function selectModel(query, config, toolCallCount = 0) {
|
|
23
34
|
if (!config.enabled || config.tiers.length === 0) {
|
|
24
35
|
return config.defaultModel;
|