scalix 0.0.1 → 1.0.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 +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/app.py +55 -0
- package/bin/scalix.js +19 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +852 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2329 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +324 -0
- package/dist/context-builder.js +151 -0
- package/dist/context-manager.js +148 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +180 -0
- package/dist/git-undo.js +67 -0
- package/dist/headless.js +654 -0
- package/dist/hooks.js +94 -0
- package/dist/ipc-server.js +228 -0
- package/dist/logger.js +45 -0
- package/dist/markdown.js +41 -0
- package/dist/mcp.js +49 -0
- package/dist/model-router.js +68 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +374 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +279 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +71 -0
- package/dist/settings.js +155 -0
- package/dist/sub-agent.js +79 -0
- package/dist/token-counter.js +47 -0
- package/dist/tool-helper.js +97 -0
- package/dist/tool-selector.js +77 -0
- package/dist/tools-advanced.js +232 -0
- package/dist/tools-browser.js +273 -0
- package/dist/tools-extra.js +383 -0
- package/dist/tools-vision.js +115 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +370 -0
- package/dist/types.js +1 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +67 -6
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const FUNCTIONS = {
|
|
2
|
+
sqrt: Math.sqrt,
|
|
3
|
+
abs: Math.abs,
|
|
4
|
+
sin: Math.sin,
|
|
5
|
+
cos: Math.cos,
|
|
6
|
+
tan: Math.tan,
|
|
7
|
+
log: Math.log,
|
|
8
|
+
log10: Math.log10,
|
|
9
|
+
floor: Math.floor,
|
|
10
|
+
ceil: Math.ceil,
|
|
11
|
+
round: Math.round,
|
|
12
|
+
min: Math.min,
|
|
13
|
+
max: Math.max,
|
|
14
|
+
pow: Math.pow,
|
|
15
|
+
};
|
|
16
|
+
const CONSTANTS = {
|
|
17
|
+
pi: Math.PI,
|
|
18
|
+
e: Math.E,
|
|
19
|
+
};
|
|
20
|
+
function tokenize(expr) {
|
|
21
|
+
const tokens = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < expr.length) {
|
|
24
|
+
if (/\s/.test(expr[i])) {
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (/[0-9.]/.test(expr[i])) {
|
|
29
|
+
let num = '';
|
|
30
|
+
while (i < expr.length && /[0-9.]/.test(expr[i])) {
|
|
31
|
+
num += expr[i++];
|
|
32
|
+
}
|
|
33
|
+
if (i < expr.length && (expr[i] === 'e' || expr[i] === 'E')) {
|
|
34
|
+
num += expr[i++];
|
|
35
|
+
if (i < expr.length && (expr[i] === '+' || expr[i] === '-')) {
|
|
36
|
+
num += expr[i++];
|
|
37
|
+
}
|
|
38
|
+
while (i < expr.length && /[0-9]/.test(expr[i])) {
|
|
39
|
+
num += expr[i++];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const value = parseFloat(num);
|
|
43
|
+
if (isNaN(value))
|
|
44
|
+
throw new Error(`Invalid number: ${num}`);
|
|
45
|
+
tokens.push({ type: 'number', value });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (/[a-zA-Z_]/.test(expr[i])) {
|
|
49
|
+
let name = '';
|
|
50
|
+
while (i < expr.length && /[a-zA-Z0-9_]/.test(expr[i])) {
|
|
51
|
+
name += expr[i++];
|
|
52
|
+
}
|
|
53
|
+
const lower = name.toLowerCase();
|
|
54
|
+
if (lower in CONSTANTS)
|
|
55
|
+
tokens.push({ type: 'number', value: CONSTANTS[lower] });
|
|
56
|
+
else if (lower in FUNCTIONS)
|
|
57
|
+
tokens.push({ type: 'func', value: lower });
|
|
58
|
+
else
|
|
59
|
+
throw new Error(`Unknown identifier: ${name}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (expr[i] === '(') {
|
|
63
|
+
tokens.push({ type: 'lparen' });
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (expr[i] === ')') {
|
|
68
|
+
tokens.push({ type: 'rparen' });
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (expr[i] === ',') {
|
|
73
|
+
tokens.push({ type: 'comma' });
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if ('+-*/%^'.includes(expr[i])) {
|
|
78
|
+
tokens.push({ type: 'op', value: expr[i] });
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Unexpected character: ${expr[i]}`);
|
|
83
|
+
}
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
class Parser {
|
|
87
|
+
tokens;
|
|
88
|
+
pos = 0;
|
|
89
|
+
constructor(tokens) { this.tokens = tokens; }
|
|
90
|
+
peek() { return this.tokens[this.pos]; }
|
|
91
|
+
consume() { return this.tokens[this.pos++]; }
|
|
92
|
+
parse() {
|
|
93
|
+
const result = this.expression();
|
|
94
|
+
if (this.pos < this.tokens.length)
|
|
95
|
+
throw new Error('Unexpected token after expression');
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
peekOp() {
|
|
99
|
+
const t = this.peek();
|
|
100
|
+
return t?.type === 'op' ? t.value : null;
|
|
101
|
+
}
|
|
102
|
+
expression() {
|
|
103
|
+
let left = this.term();
|
|
104
|
+
let op = this.peekOp();
|
|
105
|
+
while (op === '+' || op === '-') {
|
|
106
|
+
this.consume();
|
|
107
|
+
const right = this.term();
|
|
108
|
+
left = op === '+' ? left + right : left - right;
|
|
109
|
+
op = this.peekOp();
|
|
110
|
+
}
|
|
111
|
+
return left;
|
|
112
|
+
}
|
|
113
|
+
term() {
|
|
114
|
+
let left = this.power();
|
|
115
|
+
let op = this.peekOp();
|
|
116
|
+
while (op === '*' || op === '/' || op === '%') {
|
|
117
|
+
this.consume();
|
|
118
|
+
const right = this.power();
|
|
119
|
+
if (op === '*')
|
|
120
|
+
left *= right;
|
|
121
|
+
else if (op === '/') {
|
|
122
|
+
if (right === 0)
|
|
123
|
+
throw new Error('Division by zero');
|
|
124
|
+
left /= right;
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
left %= right;
|
|
128
|
+
op = this.peekOp();
|
|
129
|
+
}
|
|
130
|
+
return left;
|
|
131
|
+
}
|
|
132
|
+
power() {
|
|
133
|
+
let base = this.unary();
|
|
134
|
+
if (this.peekOp() === '^') {
|
|
135
|
+
this.consume();
|
|
136
|
+
base = Math.pow(base, this.power());
|
|
137
|
+
}
|
|
138
|
+
return base;
|
|
139
|
+
}
|
|
140
|
+
unary() {
|
|
141
|
+
const op = this.peekOp();
|
|
142
|
+
if (op === '+' || op === '-') {
|
|
143
|
+
this.consume();
|
|
144
|
+
const val = this.unary();
|
|
145
|
+
return op === '-' ? -val : val;
|
|
146
|
+
}
|
|
147
|
+
return this.primary();
|
|
148
|
+
}
|
|
149
|
+
primary() {
|
|
150
|
+
const tok = this.peek();
|
|
151
|
+
if (!tok)
|
|
152
|
+
throw new Error('Unexpected end of expression');
|
|
153
|
+
if (tok.type === 'number') {
|
|
154
|
+
this.consume();
|
|
155
|
+
return tok.value;
|
|
156
|
+
}
|
|
157
|
+
if (tok.type === 'func') {
|
|
158
|
+
const name = tok.value;
|
|
159
|
+
this.consume();
|
|
160
|
+
if (this.peek()?.type !== 'lparen')
|
|
161
|
+
throw new Error(`Expected ( after ${name}`);
|
|
162
|
+
this.consume();
|
|
163
|
+
const args = [];
|
|
164
|
+
if (this.peek()?.type !== 'rparen') {
|
|
165
|
+
args.push(this.expression());
|
|
166
|
+
while (this.peek()?.type === 'comma') {
|
|
167
|
+
this.consume();
|
|
168
|
+
args.push(this.expression());
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (this.peek()?.type !== 'rparen')
|
|
172
|
+
throw new Error(`Expected ) after ${name} arguments`);
|
|
173
|
+
this.consume();
|
|
174
|
+
return FUNCTIONS[name](...args);
|
|
175
|
+
}
|
|
176
|
+
if (tok.type === 'lparen') {
|
|
177
|
+
this.consume();
|
|
178
|
+
const val = this.expression();
|
|
179
|
+
if (this.peek()?.type !== 'rparen')
|
|
180
|
+
throw new Error('Expected closing )');
|
|
181
|
+
this.consume();
|
|
182
|
+
return val;
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Unexpected token: ${JSON.stringify(tok)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function safeEvaluate(expression) {
|
|
188
|
+
if (expression.length > 10_000)
|
|
189
|
+
throw new Error('Expression too long (max 10000 chars)');
|
|
190
|
+
const tokens = tokenize(expression);
|
|
191
|
+
if (tokens.length === 0)
|
|
192
|
+
throw new Error('Empty expression');
|
|
193
|
+
return new Parser(tokens).parse();
|
|
194
|
+
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { TOOL_DEFAULTS, UNIX_SHELL_FALLBACK } from './constants.js';
|
|
5
|
+
let _sandboxRoot = null;
|
|
6
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
7
|
+
export function setSandboxRoot(root) {
|
|
8
|
+
_sandboxRoot = path.resolve(root);
|
|
9
|
+
}
|
|
10
|
+
function getSandboxRoot() {
|
|
11
|
+
return _sandboxRoot || process.cwd();
|
|
12
|
+
}
|
|
13
|
+
function normalizeSep(p) {
|
|
14
|
+
return process.platform === 'win32' ? p.replace(/\//g, '\\') : p;
|
|
15
|
+
}
|
|
16
|
+
function isWithinRoot(p, root) {
|
|
17
|
+
const np = normalizeSep(p).toLowerCase();
|
|
18
|
+
const nr = normalizeSep(root).toLowerCase();
|
|
19
|
+
return np === nr || np.startsWith(nr + path.sep);
|
|
20
|
+
}
|
|
21
|
+
export function sandboxPath(filePath) {
|
|
22
|
+
const root = getSandboxRoot();
|
|
23
|
+
const resolved = path.resolve(root, filePath);
|
|
24
|
+
if (!isWithinRoot(resolved, root)) {
|
|
25
|
+
throw new Error(`Path traversal blocked: "${filePath}" resolves outside working directory`);
|
|
26
|
+
}
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
export async function sandboxPathAsync(filePath) {
|
|
30
|
+
const root = getSandboxRoot();
|
|
31
|
+
const resolved = path.resolve(root, filePath);
|
|
32
|
+
if (!isWithinRoot(resolved, root)) {
|
|
33
|
+
throw new Error(`Path traversal blocked: "${filePath}" resolves outside working directory`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const real = await fs.realpath(resolved);
|
|
37
|
+
if (!isWithinRoot(real, root)) {
|
|
38
|
+
throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
|
|
39
|
+
}
|
|
40
|
+
return real;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT')
|
|
44
|
+
return resolved;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const READ_ONLY_TOOLS = new Set([
|
|
49
|
+
'get_current_time',
|
|
50
|
+
'calculate',
|
|
51
|
+
'read_file',
|
|
52
|
+
'read_file_lines',
|
|
53
|
+
'list_files',
|
|
54
|
+
'search_files',
|
|
55
|
+
'find_files',
|
|
56
|
+
'tree',
|
|
57
|
+
'git_status',
|
|
58
|
+
'git_diff',
|
|
59
|
+
'web_search',
|
|
60
|
+
'monitor',
|
|
61
|
+
]);
|
|
62
|
+
const WRITE_TOOLS = new Set([
|
|
63
|
+
'write_file',
|
|
64
|
+
'edit_file',
|
|
65
|
+
'web_fetch',
|
|
66
|
+
'todo_write',
|
|
67
|
+
'notebook_edit',
|
|
68
|
+
]);
|
|
69
|
+
const DANGEROUS_TOOLS = new Set([
|
|
70
|
+
'run_command',
|
|
71
|
+
'git_commit',
|
|
72
|
+
'add_mcp_server',
|
|
73
|
+
'agent',
|
|
74
|
+
]);
|
|
75
|
+
export function getToolPermission(toolName, mode) {
|
|
76
|
+
switch (mode) {
|
|
77
|
+
case 'plan':
|
|
78
|
+
return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'block';
|
|
79
|
+
case 'default':
|
|
80
|
+
if (READ_ONLY_TOOLS.has(toolName))
|
|
81
|
+
return 'allow';
|
|
82
|
+
return 'ask';
|
|
83
|
+
case 'auto':
|
|
84
|
+
if (READ_ONLY_TOOLS.has(toolName))
|
|
85
|
+
return 'allow';
|
|
86
|
+
if (WRITE_TOOLS.has(toolName))
|
|
87
|
+
return 'allow';
|
|
88
|
+
return 'ask';
|
|
89
|
+
case 'yolo':
|
|
90
|
+
return 'allow';
|
|
91
|
+
default:
|
|
92
|
+
return 'ask';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function isDangerousTool(toolName) {
|
|
96
|
+
return DANGEROUS_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName);
|
|
97
|
+
}
|
|
98
|
+
export function getModeDescription(mode) {
|
|
99
|
+
switch (mode) {
|
|
100
|
+
case 'plan':
|
|
101
|
+
return 'Read-only. Allowed: read, search, list, git status/diff. Blocked: writes, commands, git commit.';
|
|
102
|
+
case 'default':
|
|
103
|
+
return 'Ask before writes & commands. Auto-approve: reads, search. Confirm: edit, write, run_command, git commit.';
|
|
104
|
+
case 'auto':
|
|
105
|
+
return 'Auto-approve reads & writes. Confirm only: run_command, git_commit, agent, add_mcp_server.';
|
|
106
|
+
case 'yolo':
|
|
107
|
+
return 'Full autonomy. All tools execute without confirmation. Sandbox safety checks still apply.';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export const PERMISSION_MODES = ['plan', 'default', 'auto', 'yolo'];
|
|
111
|
+
let cachedShell = null;
|
|
112
|
+
function detectShellFromEnv() {
|
|
113
|
+
if (process.env.SCALIX_SHELL)
|
|
114
|
+
return process.env.SCALIX_SHELL;
|
|
115
|
+
if (process.env.SHELL)
|
|
116
|
+
return process.env.SHELL;
|
|
117
|
+
if (IS_WINDOWS && process.env.COMSPEC)
|
|
118
|
+
return process.env.COMSPEC;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function classifyShell(shellPath) {
|
|
122
|
+
const name = path.basename(shellPath).toLowerCase().replace(/\.exe$/i, '');
|
|
123
|
+
if (name === 'bash')
|
|
124
|
+
return 'bash';
|
|
125
|
+
if (name === 'zsh')
|
|
126
|
+
return 'zsh';
|
|
127
|
+
if (name === 'fish')
|
|
128
|
+
return 'fish';
|
|
129
|
+
if (name === 'pwsh' || name === 'powershell')
|
|
130
|
+
return 'pwsh';
|
|
131
|
+
if (name === 'cmd')
|
|
132
|
+
return 'cmd';
|
|
133
|
+
return 'sh';
|
|
134
|
+
}
|
|
135
|
+
export function getShellInfo() {
|
|
136
|
+
if (cachedShell)
|
|
137
|
+
return cachedShell;
|
|
138
|
+
const envShell = detectShellFromEnv();
|
|
139
|
+
if (envShell) {
|
|
140
|
+
const shellType = classifyShell(envShell);
|
|
141
|
+
cachedShell = {
|
|
142
|
+
type: shellType,
|
|
143
|
+
path: envShell,
|
|
144
|
+
args: shellType === 'cmd' ? ['/d', '/s', '/c'] : ['-c'],
|
|
145
|
+
};
|
|
146
|
+
return cachedShell;
|
|
147
|
+
}
|
|
148
|
+
if (IS_WINDOWS) {
|
|
149
|
+
// Prefer PowerShell if available
|
|
150
|
+
try {
|
|
151
|
+
execSync('pwsh --version', { stdio: 'ignore', timeout: TOOL_DEFAULTS.shellDetectionTimeoutMs });
|
|
152
|
+
cachedShell = { type: 'pwsh', path: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command'] };
|
|
153
|
+
return cachedShell;
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
try {
|
|
157
|
+
execSync('powershell -Command "exit 0"', { stdio: 'ignore', timeout: TOOL_DEFAULTS.shellDetectionTimeoutMs });
|
|
158
|
+
cachedShell = { type: 'pwsh', path: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command'] };
|
|
159
|
+
return cachedShell;
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
cachedShell = { type: 'cmd', path: process.env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c'] };
|
|
163
|
+
return cachedShell;
|
|
164
|
+
}
|
|
165
|
+
// Unix fallback
|
|
166
|
+
cachedShell = { type: 'sh', path: UNIX_SHELL_FALLBACK, args: ['-c'] };
|
|
167
|
+
return cachedShell;
|
|
168
|
+
}
|
|
169
|
+
export function isPowerShell() {
|
|
170
|
+
const shell = getShellInfo();
|
|
171
|
+
return shell.type === 'pwsh';
|
|
172
|
+
}
|
|
173
|
+
export function isCmd() {
|
|
174
|
+
const shell = getShellInfo();
|
|
175
|
+
return shell.type === 'cmd';
|
|
176
|
+
}
|
|
177
|
+
// ─── Command Security ───
|
|
178
|
+
// Unix command blocklist
|
|
179
|
+
const UNIX_BLOCKLIST = [
|
|
180
|
+
/\brm\s+-rf\s+\//,
|
|
181
|
+
/\bmkfs\b/,
|
|
182
|
+
/\bdd\s+if=/,
|
|
183
|
+
/>(\/dev\/[a-z])/,
|
|
184
|
+
/\bshutdown\b/,
|
|
185
|
+
/\breboot\b/,
|
|
186
|
+
/\bcurl\b.*\|\s*(ba)?sh/,
|
|
187
|
+
/\bwget\b.*\|\s*(ba)?sh/,
|
|
188
|
+
];
|
|
189
|
+
// Windows/PowerShell command blocklist
|
|
190
|
+
const WINDOWS_BLOCKLIST = [
|
|
191
|
+
/\bdel\s+\/[sf]/i,
|
|
192
|
+
/\brd\s+\/s/i,
|
|
193
|
+
/\brmdir\s+\/s/i,
|
|
194
|
+
/\bformat\s+[a-z]:/i,
|
|
195
|
+
/\bdiskpart\b/i,
|
|
196
|
+
/\bnet\s+user\b/i,
|
|
197
|
+
/\bnet\s+localgroup\s+administrators/i,
|
|
198
|
+
/\breg\s+delete\b/i,
|
|
199
|
+
/\bsc\s+(delete|stop)\b/i,
|
|
200
|
+
/\bbcdedit\b/i,
|
|
201
|
+
/\bsfc\s+\/scannow/i,
|
|
202
|
+
];
|
|
203
|
+
// PowerShell-specific blocklist
|
|
204
|
+
const POWERSHELL_BLOCKLIST = [
|
|
205
|
+
/Remove-Item\s+.*-Recurse\s+.*-Force/i,
|
|
206
|
+
/Remove-Item\s+.*[/\\]\s*\*/i,
|
|
207
|
+
/Format-Volume\b/i,
|
|
208
|
+
/Clear-Disk\b/i,
|
|
209
|
+
/Stop-Service\b.*-Force/i,
|
|
210
|
+
/Set-ExecutionPolicy\s+Unrestricted/i,
|
|
211
|
+
/Invoke-WebRequest\b.*\|\s*Invoke-Expression/i,
|
|
212
|
+
/iwr\b.*\|\s*iex\b/i,
|
|
213
|
+
/Invoke-Expression\b/i,
|
|
214
|
+
/\biex\s+/i,
|
|
215
|
+
/New-LocalUser\b/i,
|
|
216
|
+
/Add-LocalGroupMember\b.*Administrators/i,
|
|
217
|
+
];
|
|
218
|
+
const COMMAND_BLOCKLIST = [
|
|
219
|
+
...UNIX_BLOCKLIST,
|
|
220
|
+
...WINDOWS_BLOCKLIST,
|
|
221
|
+
...POWERSHELL_BLOCKLIST,
|
|
222
|
+
];
|
|
223
|
+
// Unix injection patterns
|
|
224
|
+
const UNIX_INJECTION_PATTERNS = [
|
|
225
|
+
/`[^`]*`/,
|
|
226
|
+
/\$\([^)]*\)/,
|
|
227
|
+
/;\s*(rm|mkfs|dd|shutdown|reboot|curl|wget)\b/,
|
|
228
|
+
/\|\s*(ba)?sh\b/,
|
|
229
|
+
/\beval\s+/,
|
|
230
|
+
/>\s*\/etc\//,
|
|
231
|
+
/\bchmod\s+[0-7]*7[0-7]*\s/,
|
|
232
|
+
/\bchown\s+root\b/,
|
|
233
|
+
/\bnc\s+-[elp]/,
|
|
234
|
+
/\bpython[23]?\s+-c\b.*__(import|exec|eval)/,
|
|
235
|
+
];
|
|
236
|
+
// Windows/PowerShell injection patterns
|
|
237
|
+
const WINDOWS_INJECTION_PATTERNS = [
|
|
238
|
+
/&\s*\{[^}]*\}/,
|
|
239
|
+
/\$\([^)]*\)/,
|
|
240
|
+
/;\s*(del|rd|rmdir|format|diskpart|net\s+user|reg\s+delete)\b/i,
|
|
241
|
+
/\|\s*powershell\b/i,
|
|
242
|
+
/\|\s*pwsh\b/i,
|
|
243
|
+
/\|\s*cmd\b/i,
|
|
244
|
+
/\bStart-Process\b.*-Verb\s+RunAs/i,
|
|
245
|
+
/-EncodedCommand\b/i,
|
|
246
|
+
/-e\s+[A-Za-z0-9+/=]{20,}/i,
|
|
247
|
+
/\[System\.Reflection\.Assembly\]::Load/i,
|
|
248
|
+
/\[Net\.WebClient\].*DownloadString/i,
|
|
249
|
+
/\bAdd-Type\b.*-TypeDefinition/i,
|
|
250
|
+
];
|
|
251
|
+
const INJECTION_PATTERNS = [
|
|
252
|
+
...UNIX_INJECTION_PATTERNS,
|
|
253
|
+
...WINDOWS_INJECTION_PATTERNS,
|
|
254
|
+
];
|
|
255
|
+
const SENSITIVE_ENV_PATTERNS = [
|
|
256
|
+
/API_KEY/i, /SECRET/i, /TOKEN/i, /PASSWORD/i, /CREDENTIAL/i, /AUTH/i, /PRIVATE/i,
|
|
257
|
+
];
|
|
258
|
+
export function isBlockedCommand(command) {
|
|
259
|
+
return COMMAND_BLOCKLIST.some((re) => re.test(command));
|
|
260
|
+
}
|
|
261
|
+
export function detectInjection(command) {
|
|
262
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
263
|
+
if (pattern.test(command)) {
|
|
264
|
+
return `Suspicious pattern detected: ${pattern.source}`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
export function sanitizeEnvForCommand() {
|
|
270
|
+
const clean = {};
|
|
271
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
272
|
+
if (val === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key)))
|
|
275
|
+
continue;
|
|
276
|
+
clean[key] = val;
|
|
277
|
+
}
|
|
278
|
+
return clean;
|
|
279
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { PROJECT_LOOKUP_DIRS } from './constants.js';
|
|
5
|
+
const RULE_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/rules`);
|
|
6
|
+
const RULE_EXTENSIONS = ['.md', '.txt'];
|
|
7
|
+
export async function loadScopedRules(cwd) {
|
|
8
|
+
const rules = [];
|
|
9
|
+
for (const dir of RULE_DIRS) {
|
|
10
|
+
const fullDir = path.resolve(cwd, dir);
|
|
11
|
+
let entries;
|
|
12
|
+
try {
|
|
13
|
+
entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (!entry.isFile())
|
|
20
|
+
continue;
|
|
21
|
+
if (!RULE_EXTENSIONS.some((ext) => entry.name.endsWith(ext)))
|
|
22
|
+
continue;
|
|
23
|
+
const filePath = path.join(fullDir, entry.name);
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
26
|
+
const { pattern, body } = parseRuleFrontmatter(raw);
|
|
27
|
+
rules.push({
|
|
28
|
+
pattern: pattern || '*',
|
|
29
|
+
content: body.trim(),
|
|
30
|
+
source: filePath,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
logger.warn('Failed to load scoped rule', { file: filePath, error: err.message });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rules;
|
|
39
|
+
}
|
|
40
|
+
function parseRuleFrontmatter(content) {
|
|
41
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
42
|
+
const match = normalized.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
43
|
+
if (!match)
|
|
44
|
+
return { pattern: '*', body: normalized };
|
|
45
|
+
let pattern = '*';
|
|
46
|
+
for (const line of match[1].split('\n')) {
|
|
47
|
+
const idx = line.indexOf(':');
|
|
48
|
+
if (idx > 0) {
|
|
49
|
+
const key = line.slice(0, idx).trim();
|
|
50
|
+
const val = line.slice(idx + 1).trim();
|
|
51
|
+
if (key === 'pattern' || key === 'glob' || key === 'match')
|
|
52
|
+
pattern = val;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { pattern, body: match[2] };
|
|
56
|
+
}
|
|
57
|
+
export function matchRulesForFile(rules, filePath) {
|
|
58
|
+
return rules.filter((rule) => {
|
|
59
|
+
if (rule.pattern === '*')
|
|
60
|
+
return true;
|
|
61
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
62
|
+
const pattern = rule.pattern.replace(/\\/g, '/');
|
|
63
|
+
if (pattern.startsWith('*')) {
|
|
64
|
+
return normalized.endsWith(pattern.slice(1));
|
|
65
|
+
}
|
|
66
|
+
if (pattern.endsWith('*')) {
|
|
67
|
+
return normalized.startsWith(pattern.slice(0, -1));
|
|
68
|
+
}
|
|
69
|
+
return normalized === pattern || normalized.endsWith('/' + pattern);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function rulesToContext(rules) {
|
|
73
|
+
if (rules.length === 0)
|
|
74
|
+
return '';
|
|
75
|
+
const lines = ['## Project Rules'];
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
if (rule.pattern !== '*')
|
|
78
|
+
lines.push(`### Rules for ${rule.pattern}`);
|
|
79
|
+
lines.push(rule.content);
|
|
80
|
+
}
|
|
81
|
+
return lines.join('\n\n');
|
|
82
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import { USER_CONFIG_DIR } from './constants.js';
|
|
7
|
+
const SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'sessions');
|
|
8
|
+
async function ensureDir() {
|
|
9
|
+
await fs.mkdir(SESSIONS_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
function sessionPath(id) {
|
|
12
|
+
return path.join(SESSIONS_DIR, `${id}.json`);
|
|
13
|
+
}
|
|
14
|
+
export async function saveSession(id, messages, model, name, costState) {
|
|
15
|
+
await ensureDir();
|
|
16
|
+
const existing = await loadSession(id).catch(() => null);
|
|
17
|
+
const now = new Date().toISOString();
|
|
18
|
+
const meta = {
|
|
19
|
+
id,
|
|
20
|
+
name: name || existing?.meta.name || `Session ${id.slice(0, 8)}`,
|
|
21
|
+
model,
|
|
22
|
+
messageCount: messages.length,
|
|
23
|
+
createdAt: existing?.meta.createdAt || now,
|
|
24
|
+
updatedAt: now,
|
|
25
|
+
};
|
|
26
|
+
const data = { meta, messages, costState };
|
|
27
|
+
// Atomic write: write to temp file then rename to prevent corruption
|
|
28
|
+
const tmpPath = path.join(os.tmpdir(), `scalix-session-${id}-${Date.now()}-${randomUUID().slice(0, 8)}.tmp`);
|
|
29
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
30
|
+
await fs.rename(tmpPath, sessionPath(id));
|
|
31
|
+
return meta;
|
|
32
|
+
}
|
|
33
|
+
export async function loadSession(id) {
|
|
34
|
+
const raw = await fs.readFile(sessionPath(id), 'utf-8');
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Corrupted session file: ${id}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function listSessions() {
|
|
43
|
+
await ensureDir();
|
|
44
|
+
const files = await fs.readdir(SESSIONS_DIR);
|
|
45
|
+
const sessions = [];
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (!file.endsWith('.json'))
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const raw = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
|
|
51
|
+
const data = JSON.parse(raw);
|
|
52
|
+
sessions.push(data.meta);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
logger.warn('Failed to read session file', { file, error: e.message });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
59
|
+
}
|
|
60
|
+
export async function deleteSession(id) {
|
|
61
|
+
try {
|
|
62
|
+
await fs.unlink(sessionPath(id));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function generateSessionId() {
|
|
70
|
+
return randomUUID();
|
|
71
|
+
}
|