scalix 0.0.1 → 1.1.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/CHANGELOG.md +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/bin/scalix.js +19 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +1130 -0
- package/dist/api-client.js +77 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2454 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +358 -0
- package/dist/context-builder.js +158 -0
- package/dist/context-manager.js +148 -0
- package/dist/context-retriever.js +257 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +183 -0
- package/dist/git-undo.js +67 -0
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +743 -0
- package/dist/hooks.js +100 -0
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +232 -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 +376 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +403 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +106 -0
- package/dist/settings.js +260 -0
- package/dist/sub-agent.js +154 -0
- package/dist/task-board.js +105 -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 +487 -0
- package/dist/tools-browser.js +534 -0
- package/dist/tools-extra.js +390 -0
- package/dist/tools-vision.js +116 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +380 -0
- package/dist/types.js +1 -0
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +71 -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,403 @@
|
|
|
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
|
+
// ─── Security Configuration ───
|
|
49
|
+
let _securityConfig = {};
|
|
50
|
+
export function setSecurityConfig(config) {
|
|
51
|
+
_securityConfig = config;
|
|
52
|
+
_effectiveToolSets = null; // invalidate cache
|
|
53
|
+
}
|
|
54
|
+
export function getSecurityConfig() {
|
|
55
|
+
return _securityConfig;
|
|
56
|
+
}
|
|
57
|
+
// ─── Tool Classification (configurable) ───
|
|
58
|
+
const DEFAULT_READ_ONLY_TOOLS = new Set([
|
|
59
|
+
'get_current_time',
|
|
60
|
+
'calculate',
|
|
61
|
+
'read_file',
|
|
62
|
+
'read_file_lines',
|
|
63
|
+
'list_files',
|
|
64
|
+
'search_files',
|
|
65
|
+
'find_files',
|
|
66
|
+
'tree',
|
|
67
|
+
'git_status',
|
|
68
|
+
'git_diff',
|
|
69
|
+
'web_search',
|
|
70
|
+
'monitor',
|
|
71
|
+
]);
|
|
72
|
+
const DEFAULT_WRITE_TOOLS = new Set([
|
|
73
|
+
'write_file',
|
|
74
|
+
'edit_file',
|
|
75
|
+
'web_fetch',
|
|
76
|
+
'todo_write',
|
|
77
|
+
'notebook_edit',
|
|
78
|
+
'send_message',
|
|
79
|
+
'task_create',
|
|
80
|
+
'task_claim',
|
|
81
|
+
'task_update',
|
|
82
|
+
'task_list',
|
|
83
|
+
]);
|
|
84
|
+
const DEFAULT_DANGEROUS_TOOLS = new Set([
|
|
85
|
+
'run_command',
|
|
86
|
+
'git_commit',
|
|
87
|
+
'add_mcp_server',
|
|
88
|
+
'agent',
|
|
89
|
+
]);
|
|
90
|
+
let _effectiveToolSets = null;
|
|
91
|
+
function getEffectiveToolSets() {
|
|
92
|
+
if (_effectiveToolSets)
|
|
93
|
+
return _effectiveToolSets;
|
|
94
|
+
const readOnly = new Set(DEFAULT_READ_ONLY_TOOLS);
|
|
95
|
+
const write = new Set(DEFAULT_WRITE_TOOLS);
|
|
96
|
+
const dangerous = new Set(DEFAULT_DANGEROUS_TOOLS);
|
|
97
|
+
if (_securityConfig.additionalReadOnlyTools) {
|
|
98
|
+
for (const t of _securityConfig.additionalReadOnlyTools)
|
|
99
|
+
readOnly.add(t);
|
|
100
|
+
}
|
|
101
|
+
if (_securityConfig.additionalWriteTools) {
|
|
102
|
+
for (const t of _securityConfig.additionalWriteTools)
|
|
103
|
+
write.add(t);
|
|
104
|
+
}
|
|
105
|
+
if (_securityConfig.additionalDangerousTools) {
|
|
106
|
+
for (const t of _securityConfig.additionalDangerousTools)
|
|
107
|
+
dangerous.add(t);
|
|
108
|
+
}
|
|
109
|
+
if (_securityConfig.toolOverrides) {
|
|
110
|
+
for (const [tool, level] of Object.entries(_securityConfig.toolOverrides)) {
|
|
111
|
+
readOnly.delete(tool);
|
|
112
|
+
write.delete(tool);
|
|
113
|
+
dangerous.delete(tool);
|
|
114
|
+
if (level === 'read-only')
|
|
115
|
+
readOnly.add(tool);
|
|
116
|
+
else if (level === 'write')
|
|
117
|
+
write.add(tool);
|
|
118
|
+
else if (level === 'dangerous')
|
|
119
|
+
dangerous.add(tool);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
_effectiveToolSets = { readOnly, write, dangerous };
|
|
123
|
+
return _effectiveToolSets;
|
|
124
|
+
}
|
|
125
|
+
// Keep backward-compatible exports
|
|
126
|
+
const READ_ONLY_TOOLS = DEFAULT_READ_ONLY_TOOLS;
|
|
127
|
+
const WRITE_TOOLS = DEFAULT_WRITE_TOOLS;
|
|
128
|
+
const DANGEROUS_TOOLS = DEFAULT_DANGEROUS_TOOLS;
|
|
129
|
+
export function getToolPermission(toolName, mode) {
|
|
130
|
+
const sets = getEffectiveToolSets();
|
|
131
|
+
switch (mode) {
|
|
132
|
+
case 'plan':
|
|
133
|
+
return sets.readOnly.has(toolName) ? 'allow' : 'block';
|
|
134
|
+
case 'default':
|
|
135
|
+
if (sets.readOnly.has(toolName))
|
|
136
|
+
return 'allow';
|
|
137
|
+
return 'ask';
|
|
138
|
+
case 'auto':
|
|
139
|
+
if (sets.readOnly.has(toolName))
|
|
140
|
+
return 'allow';
|
|
141
|
+
if (sets.write.has(toolName))
|
|
142
|
+
return 'allow';
|
|
143
|
+
return 'ask';
|
|
144
|
+
case 'yolo':
|
|
145
|
+
return 'allow';
|
|
146
|
+
default:
|
|
147
|
+
return 'ask';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function isDangerousTool(toolName) {
|
|
151
|
+
const sets = getEffectiveToolSets();
|
|
152
|
+
return sets.dangerous.has(toolName) || sets.write.has(toolName);
|
|
153
|
+
}
|
|
154
|
+
export function getModeDescription(mode) {
|
|
155
|
+
switch (mode) {
|
|
156
|
+
case 'plan':
|
|
157
|
+
return 'Read-only. Allowed: read, search, list, git status/diff. Blocked: writes, commands, git commit.';
|
|
158
|
+
case 'default':
|
|
159
|
+
return 'Ask before writes & commands. Auto-approve: reads, search. Confirm: edit, write, run_command, git commit.';
|
|
160
|
+
case 'auto':
|
|
161
|
+
return 'Auto-approve reads & writes. Confirm only: run_command, git_commit, agent, add_mcp_server.';
|
|
162
|
+
case 'yolo':
|
|
163
|
+
return 'Full autonomy. All tools execute without confirmation. Sandbox safety checks still apply.';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export const PERMISSION_MODES = ['plan', 'default', 'auto', 'yolo'];
|
|
167
|
+
let cachedShell = null;
|
|
168
|
+
function detectShellFromEnv() {
|
|
169
|
+
if (process.env.SCALIX_SHELL)
|
|
170
|
+
return process.env.SCALIX_SHELL;
|
|
171
|
+
if (process.env.SHELL)
|
|
172
|
+
return process.env.SHELL;
|
|
173
|
+
if (IS_WINDOWS && process.env.COMSPEC)
|
|
174
|
+
return process.env.COMSPEC;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
function classifyShell(shellPath) {
|
|
178
|
+
const name = path.basename(shellPath).toLowerCase().replace(/\.exe$/i, '');
|
|
179
|
+
if (name === 'bash')
|
|
180
|
+
return 'bash';
|
|
181
|
+
if (name === 'zsh')
|
|
182
|
+
return 'zsh';
|
|
183
|
+
if (name === 'fish')
|
|
184
|
+
return 'fish';
|
|
185
|
+
if (name === 'pwsh' || name === 'powershell')
|
|
186
|
+
return 'pwsh';
|
|
187
|
+
if (name === 'cmd')
|
|
188
|
+
return 'cmd';
|
|
189
|
+
return 'sh';
|
|
190
|
+
}
|
|
191
|
+
export function getShellInfo() {
|
|
192
|
+
if (cachedShell)
|
|
193
|
+
return cachedShell;
|
|
194
|
+
const envShell = detectShellFromEnv();
|
|
195
|
+
if (envShell) {
|
|
196
|
+
const shellType = classifyShell(envShell);
|
|
197
|
+
cachedShell = {
|
|
198
|
+
type: shellType,
|
|
199
|
+
path: envShell,
|
|
200
|
+
args: shellType === 'cmd' ? ['/d', '/s', '/c'] : ['-c'],
|
|
201
|
+
};
|
|
202
|
+
return cachedShell;
|
|
203
|
+
}
|
|
204
|
+
if (IS_WINDOWS) {
|
|
205
|
+
// Prefer PowerShell if available
|
|
206
|
+
try {
|
|
207
|
+
execSync('pwsh --version', { stdio: 'ignore', timeout: TOOL_DEFAULTS.shellDetectionTimeoutMs });
|
|
208
|
+
cachedShell = { type: 'pwsh', path: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command'] };
|
|
209
|
+
return cachedShell;
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
try {
|
|
213
|
+
execSync('powershell -Command "exit 0"', { stdio: 'ignore', timeout: TOOL_DEFAULTS.shellDetectionTimeoutMs });
|
|
214
|
+
cachedShell = { type: 'pwsh', path: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command'] };
|
|
215
|
+
return cachedShell;
|
|
216
|
+
}
|
|
217
|
+
catch { }
|
|
218
|
+
cachedShell = { type: 'cmd', path: process.env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c'] };
|
|
219
|
+
return cachedShell;
|
|
220
|
+
}
|
|
221
|
+
// Unix fallback
|
|
222
|
+
cachedShell = { type: 'sh', path: UNIX_SHELL_FALLBACK, args: ['-c'] };
|
|
223
|
+
return cachedShell;
|
|
224
|
+
}
|
|
225
|
+
export function isPowerShell() {
|
|
226
|
+
const shell = getShellInfo();
|
|
227
|
+
return shell.type === 'pwsh';
|
|
228
|
+
}
|
|
229
|
+
export function isCmd() {
|
|
230
|
+
const shell = getShellInfo();
|
|
231
|
+
return shell.type === 'cmd';
|
|
232
|
+
}
|
|
233
|
+
// ─── Command Security ───
|
|
234
|
+
// Unix command blocklist
|
|
235
|
+
const UNIX_BLOCKLIST = [
|
|
236
|
+
/\brm\s+-rf\s+\//,
|
|
237
|
+
/\bmkfs\b/,
|
|
238
|
+
/\bdd\s+if=/,
|
|
239
|
+
/>(\/dev\/[a-z])/,
|
|
240
|
+
/\bshutdown\b/,
|
|
241
|
+
/\breboot\b/,
|
|
242
|
+
/\bcurl\b.*\|\s*(ba)?sh/,
|
|
243
|
+
/\bwget\b.*\|\s*(ba)?sh/,
|
|
244
|
+
];
|
|
245
|
+
// Windows/PowerShell command blocklist
|
|
246
|
+
const WINDOWS_BLOCKLIST = [
|
|
247
|
+
/\bdel\s+\/[sf]/i,
|
|
248
|
+
/\brd\s+\/s/i,
|
|
249
|
+
/\brmdir\s+\/s/i,
|
|
250
|
+
/\bformat\s+[a-z]:/i,
|
|
251
|
+
/\bdiskpart\b/i,
|
|
252
|
+
/\bnet\s+user\b/i,
|
|
253
|
+
/\bnet\s+localgroup\s+administrators/i,
|
|
254
|
+
/\breg\s+delete\b/i,
|
|
255
|
+
/\bsc\s+(delete|stop)\b/i,
|
|
256
|
+
/\bbcdedit\b/i,
|
|
257
|
+
/\bsfc\s+\/scannow/i,
|
|
258
|
+
];
|
|
259
|
+
// PowerShell-specific blocklist
|
|
260
|
+
const POWERSHELL_BLOCKLIST = [
|
|
261
|
+
/Remove-Item\s+.*-Recurse\s+.*-Force/i,
|
|
262
|
+
/Remove-Item\s+.*[/\\]\s*\*/i,
|
|
263
|
+
/Format-Volume\b/i,
|
|
264
|
+
/Clear-Disk\b/i,
|
|
265
|
+
/Stop-Service\b.*-Force/i,
|
|
266
|
+
/Set-ExecutionPolicy\s+Unrestricted/i,
|
|
267
|
+
/Invoke-WebRequest\b.*\|\s*Invoke-Expression/i,
|
|
268
|
+
/iwr\b.*\|\s*iex\b/i,
|
|
269
|
+
/Invoke-Expression\b/i,
|
|
270
|
+
/\biex\s+/i,
|
|
271
|
+
/New-LocalUser\b/i,
|
|
272
|
+
/Add-LocalGroupMember\b.*Administrators/i,
|
|
273
|
+
];
|
|
274
|
+
const COMMAND_BLOCKLIST = [
|
|
275
|
+
...UNIX_BLOCKLIST,
|
|
276
|
+
...WINDOWS_BLOCKLIST,
|
|
277
|
+
...POWERSHELL_BLOCKLIST,
|
|
278
|
+
];
|
|
279
|
+
// Unix injection patterns
|
|
280
|
+
const UNIX_INJECTION_PATTERNS = [
|
|
281
|
+
/`[^`]*`/,
|
|
282
|
+
/\$\([^)]*\)/,
|
|
283
|
+
/;\s*(rm|mkfs|dd|shutdown|reboot|curl|wget)\b/,
|
|
284
|
+
/\|\s*(ba)?sh\b/,
|
|
285
|
+
/\beval\s+/,
|
|
286
|
+
/>\s*\/etc\//,
|
|
287
|
+
/\bchmod\s+[0-7]*7[0-7]*\s/,
|
|
288
|
+
/\bchown\s+root\b/,
|
|
289
|
+
/\bnc\s+-[elp]/,
|
|
290
|
+
/\bpython[23]?\s+-c\b.*__(import|exec|eval)/,
|
|
291
|
+
];
|
|
292
|
+
// Windows/PowerShell injection patterns
|
|
293
|
+
const WINDOWS_INJECTION_PATTERNS = [
|
|
294
|
+
/&\s*\{[^}]*\}/,
|
|
295
|
+
/\$\([^)]*\)/,
|
|
296
|
+
/;\s*(del|rd|rmdir|format|diskpart|net\s+user|reg\s+delete)\b/i,
|
|
297
|
+
/\|\s*powershell\b/i,
|
|
298
|
+
/\|\s*pwsh\b/i,
|
|
299
|
+
/\|\s*cmd\b/i,
|
|
300
|
+
/\bStart-Process\b.*-Verb\s+RunAs/i,
|
|
301
|
+
/-EncodedCommand\b/i,
|
|
302
|
+
/-e\s+[A-Za-z0-9+/=]{20,}/i,
|
|
303
|
+
/\[System\.Reflection\.Assembly\]::Load/i,
|
|
304
|
+
/\[Net\.WebClient\].*DownloadString/i,
|
|
305
|
+
/\bAdd-Type\b.*-TypeDefinition/i,
|
|
306
|
+
];
|
|
307
|
+
const INJECTION_PATTERNS = [
|
|
308
|
+
...UNIX_INJECTION_PATTERNS,
|
|
309
|
+
...WINDOWS_INJECTION_PATTERNS,
|
|
310
|
+
];
|
|
311
|
+
const SENSITIVE_ENV_PATTERNS = [
|
|
312
|
+
/API_KEY/i, /SECRET/i, /TOKEN/i, /PASSWORD/i, /CREDENTIAL/i, /AUTH/i, /PRIVATE/i,
|
|
313
|
+
];
|
|
314
|
+
function getEffectiveBlocklist() {
|
|
315
|
+
const disabled = new Set(_securityConfig.disabledBlockedCommands || []);
|
|
316
|
+
const base = disabled.size > 0
|
|
317
|
+
? COMMAND_BLOCKLIST.filter(re => !disabled.has(re.source))
|
|
318
|
+
: COMMAND_BLOCKLIST;
|
|
319
|
+
const additional = (_securityConfig.additionalBlockedCommands || []).map(p => {
|
|
320
|
+
try {
|
|
321
|
+
return new RegExp(p);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}).filter((r) => r !== null);
|
|
327
|
+
return [...base, ...additional];
|
|
328
|
+
}
|
|
329
|
+
function getEffectiveInjectionPatterns() {
|
|
330
|
+
const disabled = new Set(_securityConfig.disabledInjectionPatterns || []);
|
|
331
|
+
const base = disabled.size > 0
|
|
332
|
+
? INJECTION_PATTERNS.filter(re => !disabled.has(re.source))
|
|
333
|
+
: INJECTION_PATTERNS;
|
|
334
|
+
const additional = (_securityConfig.additionalInjectionPatterns || []).map(p => {
|
|
335
|
+
try {
|
|
336
|
+
return new RegExp(p);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}).filter((r) => r !== null);
|
|
342
|
+
return [...base, ...additional];
|
|
343
|
+
}
|
|
344
|
+
function getEffectiveEnvPatterns() {
|
|
345
|
+
const additional = (_securityConfig.additionalSensitiveEnvPatterns || []).map(p => {
|
|
346
|
+
try {
|
|
347
|
+
return new RegExp(p);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}).filter((r) => r !== null);
|
|
353
|
+
const allowedPatterns = (_securityConfig.allowedEnvPatterns || []).map(p => {
|
|
354
|
+
try {
|
|
355
|
+
return new RegExp(p);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}).filter((r) => r !== null);
|
|
361
|
+
return {
|
|
362
|
+
sensitive: [...SENSITIVE_ENV_PATTERNS, ...additional],
|
|
363
|
+
allowed: new Set(), // will check against allowedPatterns dynamically
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
export function isBlockedCommand(command) {
|
|
367
|
+
return getEffectiveBlocklist().some((re) => re.test(command));
|
|
368
|
+
}
|
|
369
|
+
export function detectInjection(command) {
|
|
370
|
+
for (const pattern of getEffectiveInjectionPatterns()) {
|
|
371
|
+
if (pattern.test(command)) {
|
|
372
|
+
return `Suspicious pattern detected: ${pattern.source}`;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
export function sanitizeEnvForCommand() {
|
|
378
|
+
const { sensitive } = getEffectiveEnvPatterns();
|
|
379
|
+
const allowedPatterns = (_securityConfig.allowedEnvPatterns || []).map(p => {
|
|
380
|
+
try {
|
|
381
|
+
return new RegExp(p);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}).filter((r) => r !== null);
|
|
387
|
+
const clean = {};
|
|
388
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
389
|
+
if (val === undefined)
|
|
390
|
+
continue;
|
|
391
|
+
if (sensitive.some((p) => p.test(key))) {
|
|
392
|
+
if (allowedPatterns.length > 0 && allowedPatterns.some(p => p.test(key))) {
|
|
393
|
+
clean[key] = val;
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
clean[key] = val;
|
|
398
|
+
}
|
|
399
|
+
return clean;
|
|
400
|
+
}
|
|
401
|
+
export function getMaxImageSize() {
|
|
402
|
+
return _securityConfig.maxImageSize ?? 20 * 1024 * 1024;
|
|
403
|
+
}
|
|
@@ -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
|
+
}
|