koishi-plugin-spawn-modified 1.2.3 → 1.2.5
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/lib/index.js +124 -10
- package/lib/locales/zh-CN.json +2 -1
- package/package.json +1 -1
- package/src/index.ts +142 -13
- package/src/locales/zh-CN.yml +1 -0
package/lib/index.js
CHANGED
|
@@ -52,6 +52,7 @@ exports.inject = exports.name = exports.Config = void 0;
|
|
|
52
52
|
exports.apply = apply;
|
|
53
53
|
var child_process_1 = require("child_process");
|
|
54
54
|
var koishi_1 = require("koishi");
|
|
55
|
+
var os_1 = __importDefault(require("os"));
|
|
55
56
|
var path_1 = __importDefault(require("path"));
|
|
56
57
|
var url_1 = require("url");
|
|
57
58
|
var ansi_to_html_1 = __importDefault(require("ansi-to-html"));
|
|
@@ -100,21 +101,130 @@ function isCommandBlocked(command, mode, list) {
|
|
|
100
101
|
});
|
|
101
102
|
return mode === 'blacklist' ? hit : !hit;
|
|
102
103
|
}
|
|
104
|
+
function stripQuotes(text) {
|
|
105
|
+
return text.replace(/^['"]|['"]$/g, '');
|
|
106
|
+
}
|
|
107
|
+
function tokenizeCommand(command) {
|
|
108
|
+
var tokens = [];
|
|
109
|
+
var current = '';
|
|
110
|
+
var quote = null;
|
|
111
|
+
for (var i = 0; i < command.length; i++) {
|
|
112
|
+
var char = command[i];
|
|
113
|
+
if ((char === '"' || char === "'") && (quote === null || quote === char)) {
|
|
114
|
+
quote = quote ? null : char;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!quote && /\s/.test(char)) {
|
|
118
|
+
if (current) {
|
|
119
|
+
tokens.push(current);
|
|
120
|
+
current = '';
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
current += char;
|
|
125
|
+
}
|
|
126
|
+
if (current)
|
|
127
|
+
tokens.push(current);
|
|
128
|
+
return tokens;
|
|
129
|
+
}
|
|
130
|
+
function isPathLike(token) {
|
|
131
|
+
var trimmed = token.trim();
|
|
132
|
+
if (!trimmed)
|
|
133
|
+
return false;
|
|
134
|
+
if (/^[|&><]+$/.test(trimmed))
|
|
135
|
+
return false;
|
|
136
|
+
if (/^-{1,2}[a-zA-Z0-9][\w-]*$/.test(trimmed))
|
|
137
|
+
return false;
|
|
138
|
+
if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed))
|
|
139
|
+
return false;
|
|
140
|
+
var normalized = stripQuotes(trimmed);
|
|
141
|
+
return (/^[A-Za-z]:[\\/]/.test(normalized) ||
|
|
142
|
+
normalized.startsWith('/') ||
|
|
143
|
+
normalized.startsWith('~') ||
|
|
144
|
+
normalized.startsWith('..') ||
|
|
145
|
+
normalized.startsWith('./') ||
|
|
146
|
+
normalized.includes('/') ||
|
|
147
|
+
normalized.includes('\\'));
|
|
148
|
+
}
|
|
149
|
+
function resolveCandidatePath(candidate, currentDir) {
|
|
150
|
+
var _a;
|
|
151
|
+
var cleaned = stripQuotes(candidate.trim());
|
|
152
|
+
var homeDir = ((_a = os_1.default.homedir) === null || _a === void 0 ? void 0 : _a.call(os_1.default)) || '';
|
|
153
|
+
if (cleaned.startsWith('~')) {
|
|
154
|
+
var withoutTilde = cleaned.slice(1).replace(/^[/\\]/, '');
|
|
155
|
+
var homeResolved = homeDir ? path_1.default.join(homeDir, withoutTilde) : cleaned;
|
|
156
|
+
return path_1.default.resolve(homeResolved);
|
|
157
|
+
}
|
|
158
|
+
return path_1.default.resolve(currentDir, cleaned);
|
|
159
|
+
}
|
|
160
|
+
function extractPathCandidates(command) {
|
|
161
|
+
var tokens = tokenizeCommand(command);
|
|
162
|
+
var candidates = [];
|
|
163
|
+
for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) {
|
|
164
|
+
var token = tokens_1[_i];
|
|
165
|
+
var normalized = stripQuotes(token);
|
|
166
|
+
if (isPathLike(normalized)) {
|
|
167
|
+
candidates.push(normalized);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
var eqIndex = normalized.indexOf('=');
|
|
171
|
+
if (eqIndex > 0) {
|
|
172
|
+
var value = normalized.slice(eqIndex + 1);
|
|
173
|
+
if (isPathLike(value)) {
|
|
174
|
+
candidates.push(value);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return candidates;
|
|
179
|
+
}
|
|
103
180
|
// 解析 cd 命令并验证路径
|
|
104
|
-
function
|
|
105
|
-
var
|
|
106
|
-
|
|
181
|
+
function isWithinRoot(rootDir, targetPath) {
|
|
182
|
+
var relative = path_1.default.relative(rootDir, targetPath);
|
|
183
|
+
return relative === '' || (!relative.startsWith('..') && !path_1.default.isAbsolute(relative));
|
|
184
|
+
}
|
|
185
|
+
function validatePathAccess(command, currentDir, rootDir, restrictDirectory) {
|
|
186
|
+
if (!restrictDirectory)
|
|
107
187
|
return { valid: true };
|
|
188
|
+
var normalizedRoot = path_1.default.resolve(rootDir);
|
|
189
|
+
var candidates = extractPathCandidates(command);
|
|
190
|
+
for (var _i = 0, candidates_1 = candidates; _i < candidates_1.length; _i++) {
|
|
191
|
+
var candidate = candidates_1[_i];
|
|
192
|
+
var resolved = resolveCandidatePath(candidate, currentDir);
|
|
193
|
+
if (!isWithinRoot(normalizedRoot, resolved)) {
|
|
194
|
+
return { valid: false, error: 'restricted-path' };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { valid: true };
|
|
198
|
+
}
|
|
199
|
+
function validateCdCommand(command, currentDir, rootDir, restrictDirectory) {
|
|
108
200
|
if (!restrictDirectory)
|
|
109
201
|
return { valid: true };
|
|
110
|
-
var targetPath = cdMatch[1].trim().replace(/['"]/g, '');
|
|
111
|
-
var absolutePath = path_1.default.resolve(currentDir, targetPath);
|
|
112
202
|
var normalizedRoot = path_1.default.resolve(rootDir);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
203
|
+
var cdMatches = [];
|
|
204
|
+
var cdRegex = /\bcd\s+([^;&|\n]+)/gi;
|
|
205
|
+
var m;
|
|
206
|
+
while ((m = cdRegex.exec(command)) !== null) {
|
|
207
|
+
cdMatches.push(m);
|
|
208
|
+
}
|
|
209
|
+
if (!cdMatches.length)
|
|
210
|
+
return { valid: true };
|
|
211
|
+
// 若命令被链式运算符分隔且包含 cd,则要求所有 cd 目标都在指定 root 下,否则拒绝
|
|
212
|
+
for (var _i = 0, cdMatches_1 = cdMatches; _i < cdMatches_1.length; _i++) {
|
|
213
|
+
var match = cdMatches_1[_i];
|
|
214
|
+
var target = match[1].trim().replace(/['"]/g, '');
|
|
215
|
+
var absolutePath = path_1.default.resolve(currentDir, target);
|
|
216
|
+
if (!isWithinRoot(normalizedRoot, absolutePath)) {
|
|
217
|
+
return { valid: false, error: 'restricted-directory' };
|
|
218
|
+
}
|
|
116
219
|
}
|
|
117
|
-
|
|
220
|
+
// 仅当命令是单独的 cd 时才更新会话目录,避免链式命令切换目录后执行其他操作
|
|
221
|
+
var singleCdOnly = /^\s*cd\s+[^;&|\n]+\s*$/i.test(command);
|
|
222
|
+
if (singleCdOnly) {
|
|
223
|
+
var target = cdMatches[0][1].trim().replace(/['"]/g, '');
|
|
224
|
+
var absolutePath = path_1.default.resolve(currentDir, target);
|
|
225
|
+
return { valid: true, newDir: absolutePath };
|
|
226
|
+
}
|
|
227
|
+
return { valid: true };
|
|
118
228
|
}
|
|
119
229
|
// 渲染终端输出为图片
|
|
120
230
|
function renderTerminalImage(ctx, workingDir, command, output) {
|
|
@@ -191,7 +301,7 @@ function apply(ctx, config) {
|
|
|
191
301
|
ctx.i18n.define('zh-CN', require('./locales/zh-CN'));
|
|
192
302
|
ctx.command('exec <command:text>', { authority: (_a = config.authority) !== null && _a !== void 0 ? _a : 4 })
|
|
193
303
|
.action(function (_a, command_1) { return __awaiter(_this, [_a, command_1], void 0, function (_b, command) {
|
|
194
|
-
var filterList, filterMode, sessionId, rootDir, currentDir, cdValidation, timeout, state;
|
|
304
|
+
var filterList, filterMode, sessionId, rootDir, currentDir, cdValidation, pathValidation, timeout, state;
|
|
195
305
|
var _this = this;
|
|
196
306
|
var _c;
|
|
197
307
|
var session = _b.session;
|
|
@@ -214,6 +324,10 @@ function apply(ctx, config) {
|
|
|
214
324
|
if (!cdValidation.valid) {
|
|
215
325
|
return [2 /*return*/, session.text('.restricted-directory')];
|
|
216
326
|
}
|
|
327
|
+
pathValidation = validatePathAccess(command, currentDir, rootDir, config.restrictDirectory);
|
|
328
|
+
if (!pathValidation.valid) {
|
|
329
|
+
return [2 /*return*/, session.text('.restricted-path')];
|
|
330
|
+
}
|
|
217
331
|
timeout = config.timeout;
|
|
218
332
|
state = { command: command, timeout: timeout, output: '' };
|
|
219
333
|
if (!!config.renderImage) return [3 /*break*/, 2];
|
package/lib/locales/zh-CN.json
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec } from 'child_process'
|
|
2
2
|
import { Context, h, Schema, Time } from 'koishi'
|
|
3
|
+
import os from 'os'
|
|
3
4
|
import path from 'path'
|
|
4
5
|
import { pathToFileURL } from 'url'
|
|
5
6
|
import AnsiToHtml from 'ansi-to-html'
|
|
@@ -83,23 +84,147 @@ function isCommandBlocked(command: string, mode: 'blacklist' | 'whitelist', list
|
|
|
83
84
|
return mode === 'blacklist' ? hit : !hit
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
function stripQuotes(text: string): string {
|
|
88
|
+
return text.replace(/^['"]|['"]$/g, '')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tokenizeCommand(command: string): string[] {
|
|
92
|
+
const tokens: string[] = []
|
|
93
|
+
let current = ''
|
|
94
|
+
let quote: string | null = null
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < command.length; i++) {
|
|
97
|
+
const char = command[i]
|
|
98
|
+
|
|
99
|
+
if ((char === '"' || char === "'") && (quote === null || quote === char)) {
|
|
100
|
+
quote = quote ? null : char
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!quote && /\s/.test(char)) {
|
|
105
|
+
if (current) {
|
|
106
|
+
tokens.push(current)
|
|
107
|
+
current = ''
|
|
108
|
+
}
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
current += char
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (current) tokens.push(current)
|
|
116
|
+
return tokens
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isPathLike(token: string): boolean {
|
|
120
|
+
const trimmed = token.trim()
|
|
121
|
+
if (!trimmed) return false
|
|
122
|
+
if (/^[|&><]+$/.test(trimmed)) return false
|
|
123
|
+
if (/^-{1,2}[a-zA-Z0-9][\w-]*$/.test(trimmed)) return false
|
|
124
|
+
if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return false
|
|
125
|
+
|
|
126
|
+
const normalized = stripQuotes(trimmed)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
/^[A-Za-z]:[\\/]/.test(normalized) ||
|
|
130
|
+
normalized.startsWith('/') ||
|
|
131
|
+
normalized.startsWith('~') ||
|
|
132
|
+
normalized.startsWith('..') ||
|
|
133
|
+
normalized.startsWith('./') ||
|
|
134
|
+
normalized.includes('/') ||
|
|
135
|
+
normalized.includes('\\')
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveCandidatePath(candidate: string, currentDir: string): string {
|
|
140
|
+
const cleaned = stripQuotes(candidate.trim())
|
|
141
|
+
const homeDir = os.homedir?.() || ''
|
|
142
|
+
|
|
143
|
+
if (cleaned.startsWith('~')) {
|
|
144
|
+
const withoutTilde = cleaned.slice(1).replace(/^[/\\]/, '')
|
|
145
|
+
const homeResolved = homeDir ? path.join(homeDir, withoutTilde) : cleaned
|
|
146
|
+
return path.resolve(homeResolved)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return path.resolve(currentDir, cleaned)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractPathCandidates(command: string): string[] {
|
|
153
|
+
const tokens = tokenizeCommand(command)
|
|
154
|
+
const candidates: string[] = []
|
|
155
|
+
|
|
156
|
+
for (const token of tokens) {
|
|
157
|
+
const normalized = stripQuotes(token)
|
|
158
|
+
if (isPathLike(normalized)) {
|
|
159
|
+
candidates.push(normalized)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const eqIndex = normalized.indexOf('=')
|
|
164
|
+
if (eqIndex > 0) {
|
|
165
|
+
const value = normalized.slice(eqIndex + 1)
|
|
166
|
+
if (isPathLike(value)) {
|
|
167
|
+
candidates.push(value)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return candidates
|
|
173
|
+
}
|
|
174
|
+
|
|
86
175
|
// 解析 cd 命令并验证路径
|
|
176
|
+
function isWithinRoot(rootDir: string, targetPath: string): boolean {
|
|
177
|
+
const relative = path.relative(rootDir, targetPath)
|
|
178
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function validatePathAccess(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; error?: string } {
|
|
182
|
+
if (!restrictDirectory) return { valid: true }
|
|
183
|
+
|
|
184
|
+
const normalizedRoot = path.resolve(rootDir)
|
|
185
|
+
const candidates = extractPathCandidates(command)
|
|
186
|
+
|
|
187
|
+
for (const candidate of candidates) {
|
|
188
|
+
const resolved = resolveCandidatePath(candidate, currentDir)
|
|
189
|
+
if (!isWithinRoot(normalizedRoot, resolved)) {
|
|
190
|
+
return { valid: false, error: 'restricted-path' }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { valid: true }
|
|
195
|
+
}
|
|
196
|
+
|
|
87
197
|
function validateCdCommand(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; newDir?: string; error?: string } {
|
|
88
|
-
const cdMatch = command.trim().match(/^cd\s+(.+)$/i)
|
|
89
|
-
if (!cdMatch) return { valid: true }
|
|
90
|
-
|
|
91
198
|
if (!restrictDirectory) return { valid: true }
|
|
92
|
-
|
|
93
|
-
const targetPath = cdMatch[1].trim().replace(/['"]/g, '')
|
|
94
|
-
const absolutePath = path.resolve(currentDir, targetPath)
|
|
199
|
+
|
|
95
200
|
const normalizedRoot = path.resolve(rootDir)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
201
|
+
const cdMatches: RegExpExecArray[] = []
|
|
202
|
+
const cdRegex = /\bcd\s+([^;&|\n]+)/gi
|
|
203
|
+
let m: RegExpExecArray | null
|
|
204
|
+
while ((m = cdRegex.exec(command)) !== null) {
|
|
205
|
+
cdMatches.push(m)
|
|
100
206
|
}
|
|
101
|
-
|
|
102
|
-
return { valid: true
|
|
207
|
+
|
|
208
|
+
if (!cdMatches.length) return { valid: true }
|
|
209
|
+
|
|
210
|
+
// 若命令被链式运算符分隔且包含 cd,则要求所有 cd 目标都在指定 root 下,否则拒绝
|
|
211
|
+
for (const match of cdMatches) {
|
|
212
|
+
const target = match[1].trim().replace(/['"]/g, '')
|
|
213
|
+
const absolutePath = path.resolve(currentDir, target)
|
|
214
|
+
if (!isWithinRoot(normalizedRoot, absolutePath)) {
|
|
215
|
+
return { valid: false, error: 'restricted-directory' }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 仅当命令是单独的 cd 时才更新会话目录,避免链式命令切换目录后执行其他操作
|
|
220
|
+
const singleCdOnly = /^\s*cd\s+[^;&|\n]+\s*$/i.test(command)
|
|
221
|
+
if (singleCdOnly) {
|
|
222
|
+
const target = cdMatches[0][1].trim().replace(/['"]/g, '')
|
|
223
|
+
const absolutePath = path.resolve(currentDir, target)
|
|
224
|
+
return { valid: true, newDir: absolutePath }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { valid: true }
|
|
103
228
|
}
|
|
104
229
|
|
|
105
230
|
// 渲染终端输出为图片
|
|
@@ -292,7 +417,7 @@ export function apply(ctx: Context, config: Config) {
|
|
|
292
417
|
}
|
|
293
418
|
|
|
294
419
|
command = h('', h.parse(command)).toString(true)
|
|
295
|
-
//
|
|
420
|
+
// 检查命令过滤(黑/白名单);仅使用配置提供的正则
|
|
296
421
|
const filterList = (config.commandList?.length ? config.commandList : config.blockedCommands) || []
|
|
297
422
|
const filterMode = config.commandFilterMode || 'blacklist'
|
|
298
423
|
if (isCommandBlocked(command, filterMode, filterList)) {
|
|
@@ -306,6 +431,10 @@ export function apply(ctx: Context, config: Config) {
|
|
|
306
431
|
if (!cdValidation.valid) {
|
|
307
432
|
return session.text('.restricted-directory')
|
|
308
433
|
}
|
|
434
|
+
const pathValidation = validatePathAccess(command, currentDir, rootDir, config.restrictDirectory)
|
|
435
|
+
if (!pathValidation.valid) {
|
|
436
|
+
return session.text('.restricted-path')
|
|
437
|
+
}
|
|
309
438
|
const { timeout } = config
|
|
310
439
|
const state: State = { command, timeout, output: '' }
|
|
311
440
|
if (!config.renderImage) {
|
package/src/locales/zh-CN.yml
CHANGED