koishi-plugin-spawn-modified 1.1.10 → 1.1.12

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.
@@ -0,0 +1,11 @@
1
+ commands:
2
+ exec:
3
+ description: 执行命令
4
+ messages:
5
+ expect-text: 请输入要运行的命令。
6
+ started: '[运行开始] {command}'
7
+ finished: |-
8
+ [运行完毕] {command}
9
+ {output}
10
+ blocked-command: 该命令已被禁止执行。
11
+ restricted-directory: 不允许切换到上级或其他目录。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-spawn-modified",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "Run shell commands with Koishi",
5
5
  "keywords": [
6
6
  "bot",
@@ -51,15 +51,17 @@
51
51
  "fonts"
52
52
  ],
53
53
  "scripts": {
54
- "build": "tsc -b",
54
+ "build": "tsc -b && node scripts/copy-locales.js",
55
55
  "clean": "rimraf lib",
56
- "prepare": "npm run build"
56
+ "prepare": "npm run build",
57
+ "prepublishOnly": "npm run clean && npm run build"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@koishijs/plugin-help": "^2.4.3",
60
61
  "@koishijs/plugin-mock": "^2.6.5",
61
62
  "koishi": "^4.17.2",
62
- "typescript": "^5.9.3"
63
+ "typescript": "^5.9.3",
64
+ "rimraf": "^5.0.5"
63
65
  },
64
66
  "peerDependencies": {
65
67
  "koishi": "^4.17.2"
package/src/index.ts CHANGED
@@ -21,6 +21,9 @@ export interface Config {
21
21
  renderImage?: boolean
22
22
  blockedCommands?: string[]
23
23
  restrictDirectory?: boolean
24
+ authority?: number
25
+ commandFilterMode?: 'blacklist' | 'whitelist'
26
+ commandList?: string[]
24
27
  }
25
28
 
26
29
  export const Config: Schema<Config> = Schema.object({
@@ -31,6 +34,9 @@ export const Config: Schema<Config> = Schema.object({
31
34
  renderImage: Schema.boolean().description('是否将命令执行结果渲染为图片(需要安装 puppeteer 插件)。').default(false),
32
35
  blockedCommands: Schema.array(String).description('违禁命令列表(命令的开头部分)。').default([]),
33
36
  restrictDirectory: Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
37
+ authority: Schema.number().description('exec 命令所需权限等级。').default(4),
38
+ commandFilterMode: Schema.union(['blacklist', 'whitelist']).description('命令过滤模式:blacklist/whitelist').default('blacklist'),
39
+ commandList: Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([]),
34
40
  })
35
41
 
36
42
  export interface State {
@@ -51,10 +57,12 @@ export const inject = {
51
57
  // 当前工作目录状态管理
52
58
  const sessionDirs = new Map<string, string>()
53
59
 
54
- // 验证命令是否被禁止
55
- function isCommandBlocked(command: string, blockedCommands: string[]): boolean {
60
+ // 命令过滤:支持黑名单/白名单模式
61
+ function isCommandBlocked(command: string, mode: 'blacklist' | 'whitelist', list: string[]): boolean {
62
+ if (!list?.length) return false
56
63
  const trimmedCommand = command.trim().toLowerCase()
57
- return blockedCommands.some(blocked => trimmedCommand.startsWith(blocked.toLowerCase()))
64
+ const hit = list.some(entry => trimmedCommand.startsWith(entry.toLowerCase()))
65
+ return mode === 'blacklist' ? hit : !hit
58
66
  }
59
67
 
60
68
  // 解析 cd 命令并验证路径
@@ -219,14 +227,16 @@ function escapeHtml(text: string): string {
219
227
  export function apply(ctx: Context, config: Config) {
220
228
  ctx.i18n.define('zh-CN', require('./locales/zh-CN'))
221
229
 
222
- ctx.command('exec <command:text>', { authority: 4 })
230
+ ctx.command('exec <command:text>', { authority: config.authority ?? 4 })
223
231
  .action(async ({ session }, command) => {
224
232
  if (!command) return session.text('.expect-text')
225
233
 
226
234
  command = h('', h.parse(command)).toString(true)
227
235
 
228
- // 检查违禁命令
229
- if (isCommandBlocked(command, config.blockedCommands)) {
236
+ // 检查命令过滤(黑/白名单)
237
+ const filterList = (config.commandList?.length ? config.commandList : config.blockedCommands) || []
238
+ const filterMode = config.commandFilterMode || 'blacklist'
239
+ if (isCommandBlocked(command, filterMode, filterList)) {
230
240
  return session.text('.blocked-command')
231
241
  }
232
242
 
@@ -2,7 +2,7 @@ commands:
2
2
  exec:
3
3
  description: 执行命令
4
4
  messages:
5
- expect-input: 请输入要运行的命令。
5
+ expect-text: 请输入要运行的命令。
6
6
  started: '[运行开始] {command}'
7
7
  finished: |-
8
8
  [运行完毕] {command}
package/lib/index.d.ts DELETED
@@ -1,33 +0,0 @@
1
- import { Context, Schema } from 'koishi';
2
- declare module 'koishi' {
3
- interface Context {
4
- puppeteer?: {
5
- page(): Promise<any>;
6
- };
7
- }
8
- }
9
- declare const encodings: readonly ["utf8", "utf16le", "latin1", "ucs2"];
10
- export interface Config {
11
- root?: string;
12
- shell?: string;
13
- encoding?: typeof encodings[number];
14
- timeout?: number;
15
- renderImage?: boolean;
16
- blockedCommands?: string[];
17
- restrictDirectory?: boolean;
18
- }
19
- export declare const Config: Schema<Config>;
20
- export interface State {
21
- command: string;
22
- timeout: number;
23
- output: string;
24
- code?: number;
25
- signal?: NodeJS.Signals;
26
- timeUsed?: number;
27
- }
28
- export declare const name = "spawn";
29
- export declare const inject: {
30
- optional: string[];
31
- };
32
- export declare function apply(ctx: Context, config: Config): void;
33
- export {};
package/lib/index.js DELETED
@@ -1,248 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.inject = exports.name = exports.Config = void 0;
7
- exports.apply = apply;
8
- const child_process_1 = require("child_process");
9
- const koishi_1 = require("koishi");
10
- const path_1 = __importDefault(require("path"));
11
- const url_1 = require("url");
12
- const encodings = ['utf8', 'utf16le', 'latin1', 'ucs2'];
13
- exports.Config = koishi_1.Schema.object({
14
- root: koishi_1.Schema.string().description('工作路径。').default(''),
15
- shell: koishi_1.Schema.string().description('运行命令的程序。'),
16
- encoding: koishi_1.Schema.union(encodings).description('输出内容编码。').default('utf8'),
17
- timeout: koishi_1.Schema.number().description('最长运行时间。').default(koishi_1.Time.minute),
18
- renderImage: koishi_1.Schema.boolean().description('是否将命令执行结果渲染为图片(需要安装 puppeteer 插件)。').default(false),
19
- blockedCommands: koishi_1.Schema.array(String).description('违禁命令列表(命令的开头部分)。').default([]),
20
- restrictDirectory: koishi_1.Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
21
- });
22
- exports.name = 'spawn';
23
- exports.inject = {
24
- optional: ['puppeteer'],
25
- };
26
- // 当前工作目录状态管理
27
- const sessionDirs = new Map();
28
- // 验证命令是否被禁止
29
- function isCommandBlocked(command, blockedCommands) {
30
- const trimmedCommand = command.trim().toLowerCase();
31
- return blockedCommands.some(blocked => trimmedCommand.startsWith(blocked.toLowerCase()));
32
- }
33
- // 解析 cd 命令并验证路径
34
- function validateCdCommand(command, currentDir, rootDir, restrictDirectory) {
35
- const cdMatch = command.trim().match(/^cd\s+(.+)$/i);
36
- if (!cdMatch)
37
- return { valid: true };
38
- if (!restrictDirectory)
39
- return { valid: true };
40
- const targetPath = cdMatch[1].trim().replace(/['"]/g, '');
41
- const absolutePath = path_1.default.resolve(currentDir, targetPath);
42
- const normalizedRoot = path_1.default.resolve(rootDir);
43
- // 检查目标路径是否在根目录内
44
- if (!absolutePath.startsWith(normalizedRoot)) {
45
- return { valid: false, error: 'restricted-directory' };
46
- }
47
- return { valid: true, newDir: absolutePath };
48
- }
49
- // 渲染终端输出为图片
50
- async function renderTerminalImage(ctx, workingDir, command, output) {
51
- if (!ctx.puppeteer) {
52
- throw new Error('Puppeteer plugin is not available');
53
- }
54
- const fontPath = (0, url_1.pathToFileURL)(path_1.default.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href;
55
- const html = `
56
- <!DOCTYPE html>
57
- <html>
58
- <head>
59
- <meta charset="UTF-8">
60
- <style>
61
- @font-face {
62
- font-family: 'JetBrains Mono';
63
- src: url('${fontPath}') format('truetype');
64
- }
65
-
66
- * {
67
- margin: 0;
68
- padding: 0;
69
- box-sizing: border-box;
70
- }
71
-
72
- body {
73
- background: #1e1e1e;
74
- color: #cccccc;
75
- font-family: 'JetBrains Mono', 'Courier New', monospace;
76
- font-size: 14px;
77
- padding: 0;
78
- display: inline-block;
79
- min-width: 600px;
80
- max-width: 1200px;
81
- }
82
-
83
- .terminal {
84
- background: #1e1e1e;
85
- border: 1px solid #3c3c3c;
86
- border-radius: 8px;
87
- overflow: hidden;
88
- }
89
-
90
- .title-bar {
91
- background: #2d2d2d;
92
- height: 35px;
93
- display: flex;
94
- align-items: center;
95
- justify-content: space-between;
96
- padding: 0 12px;
97
- border-bottom: 1px solid #3c3c3c;
98
- }
99
-
100
- .title {
101
- color: #cccccc;
102
- font-size: 13px;
103
- font-weight: 500;
104
- }
105
-
106
- .buttons {
107
- display: flex;
108
- gap: 8px;
109
- }
110
-
111
- .button {
112
- width: 12px;
113
- height: 12px;
114
- border-radius: 50%;
115
- }
116
-
117
- .button.minimize { background: #ffbd2e; }
118
- .button.maximize { background: #28c940; }
119
- .button.close { background: #ff5f56; }
120
-
121
- .content {
122
- padding: 16px;
123
- white-space: pre-wrap;
124
- word-break: break-all;
125
- line-height: 1.5;
126
- }
127
-
128
- .prompt {
129
- color: #4ec9b0;
130
- margin-bottom: 8px;
131
- }
132
-
133
- .command {
134
- color: #dcdcaa;
135
- margin-bottom: 12px;
136
- }
137
-
138
- .output {
139
- color: #cccccc;
140
- }
141
- </style>
142
- </head>
143
- <body>
144
- <div class="terminal">
145
- <div class="title-bar">
146
- <div class="title">Terminal</div>
147
- <div class="buttons">
148
- <div class="button minimize"></div>
149
- <div class="button maximize"></div>
150
- <div class="button close"></div>
151
- </div>
152
- </div>
153
- <div class="content">
154
- <div class="prompt">${escapeHtml(workingDir)}$</div>
155
- <div class="command">${escapeHtml(command)}</div>
156
- <div class="output">${escapeHtml(output)}</div>
157
- </div>
158
- </div>
159
- </body>
160
- </html>
161
- `;
162
- const page = await ctx.puppeteer.page();
163
- try {
164
- await page.setContent(html);
165
- await page.waitForNetworkIdle({ timeout: 5000 });
166
- const element = await page.$('.terminal');
167
- const clip = await element.boundingBox();
168
- const screenshot = await page.screenshot({ clip });
169
- return koishi_1.h.image(screenshot, 'image/png');
170
- }
171
- finally {
172
- await page.close();
173
- }
174
- }
175
- function escapeHtml(text) {
176
- return text
177
- .replace(/&/g, '&amp;')
178
- .replace(/</g, '&lt;')
179
- .replace(/>/g, '&gt;')
180
- .replace(/"/g, '&quot;')
181
- .replace(/'/g, '&#039;');
182
- }
183
- function apply(ctx, config) {
184
- ctx.i18n.define('zh-CN', require('./locales/zh-CN'));
185
- ctx.command('exec <command:text>', { authority: 4 })
186
- .action(async ({ session }, command) => {
187
- if (!command)
188
- return session.text('.expect-text');
189
- command = (0, koishi_1.h)('', koishi_1.h.parse(command)).toString(true);
190
- // 检查违禁命令
191
- if (isCommandBlocked(command, config.blockedCommands)) {
192
- return session.text('.blocked-command');
193
- }
194
- const sessionId = session.uid || session.channelId;
195
- const rootDir = path_1.default.resolve(ctx.baseDir, config.root);
196
- const currentDir = sessionDirs.get(sessionId) || rootDir;
197
- // 验证 cd 命令
198
- const cdValidation = validateCdCommand(command, currentDir, rootDir, config.restrictDirectory);
199
- if (!cdValidation.valid) {
200
- return session.text('.restricted-directory');
201
- }
202
- const { timeout } = config;
203
- const state = { command, timeout, output: '' };
204
- if (!config.renderImage) {
205
- await session.send(session.text('.started', state));
206
- }
207
- return new Promise((resolve) => {
208
- const start = Date.now();
209
- const child = (0, child_process_1.exec)(command, {
210
- timeout,
211
- cwd: currentDir,
212
- encoding: config.encoding,
213
- shell: config.shell,
214
- windowsHide: true,
215
- });
216
- child.stdout.on('data', (data) => {
217
- state.output += data.toString();
218
- });
219
- child.stderr.on('data', (data) => {
220
- state.output += data.toString();
221
- });
222
- child.on('close', async (code, signal) => {
223
- state.code = code;
224
- state.signal = signal;
225
- state.timeUsed = Date.now() - start;
226
- state.output = state.output.trim();
227
- // 更新当前目录(如果是 cd 命令且执行成功)
228
- if (cdValidation.newDir && code === 0) {
229
- sessionDirs.set(sessionId, cdValidation.newDir);
230
- }
231
- // 渲染为图片或返回文本
232
- if (config.renderImage && ctx.puppeteer) {
233
- try {
234
- const image = await renderTerminalImage(ctx, currentDir, command, state.output || '(no output)');
235
- resolve(image);
236
- }
237
- catch (error) {
238
- ctx.logger.error('Failed to render terminal image:', error);
239
- resolve(session.text('.finished', state));
240
- }
241
- }
242
- else {
243
- resolve(session.text('.finished', state));
244
- }
245
- });
246
- });
247
- });
248
- }