@ct-agents/tools 0.0.1
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/package.json +20 -0
- package/src/builtin-tools/ask-user.ts +115 -0
- package/src/builtin-tools/index.ts +1 -0
- package/src/index.ts +75 -0
- package/src/sandbox-tools/approval.ts +120 -0
- package/src/sandbox-tools/bash.ts +231 -0
- package/src/sandbox-tools/db-execute.ts +231 -0
- package/src/sandbox-tools/db-query.ts +211 -0
- package/src/sandbox-tools/fs-read.ts +129 -0
- package/src/sandbox-tools/fs-write.ts +132 -0
- package/src/sandbox-tools/index.ts +39 -0
- package/src/sandbox-tools/path-jail.ts +36 -0
- package/src/sandbox-tools/shared.ts +64 -0
- package/src/sandbox-tools/sql-guard.ts +128 -0
- package/src/sandbox-tools/truncate.ts +63 -0
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ct-agents/tools",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./builtin-tools": "./src/builtin-tools/index.ts",
|
|
11
|
+
"./sandbox-tools": "./src/sandbox-tools/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"zod": "4.4.3",
|
|
15
|
+
"@ct-agents/protocol": "0.0.1"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
AgentActionRequestedPayload,
|
|
4
|
+
ToolExecution,
|
|
5
|
+
ToolHandler,
|
|
6
|
+
} from '@ct-agents/protocol';
|
|
7
|
+
|
|
8
|
+
export const askUserToolInputSchema = z.object({
|
|
9
|
+
question: z.string().trim().min(1, 'question 为必填字段'),
|
|
10
|
+
options: z.array(z.string().trim().min(1)).optional(),
|
|
11
|
+
multiSelect: z.boolean().optional(),
|
|
12
|
+
}).strict();
|
|
13
|
+
|
|
14
|
+
export type AskUserToolInput = z.infer<typeof askUserToolInputSchema>;
|
|
15
|
+
export type AskUserToolResult = {
|
|
16
|
+
answer: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const askUserToolResultSchema = z.object({
|
|
20
|
+
answer: z.unknown(),
|
|
21
|
+
}).strict().refine(
|
|
22
|
+
(value) => Object.prototype.hasOwnProperty.call(value, 'answer'),
|
|
23
|
+
{ message: 'answer 为必填字段' },
|
|
24
|
+
) as z.ZodType<AskUserToolResult>;
|
|
25
|
+
|
|
26
|
+
function createAskUserActionPayload(input: AskUserToolInput): AgentActionRequestedPayload {
|
|
27
|
+
const payload: AgentActionRequestedPayload = {
|
|
28
|
+
kind: 'ask_user',
|
|
29
|
+
prompt: input.question,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (input.options && input.options.length > 0) {
|
|
33
|
+
payload.allowedResults = input.options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (input.multiSelect !== undefined) {
|
|
37
|
+
payload.input = { multiSelect: input.multiSelect };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return payload;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveAskUserAnswer(result: unknown): unknown {
|
|
44
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
45
|
+
const record = result as Record<string, unknown>;
|
|
46
|
+
if ('answer' in record) {
|
|
47
|
+
return record.answer;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 人机交互提问工具。
|
|
56
|
+
*
|
|
57
|
+
* handler 只描述“需要用户回答”和“回答如何回到模型上下文”;
|
|
58
|
+
* action 事件和 tool.completed 仍由 Environment 统一写入,避免工具绕开生命周期边界。
|
|
59
|
+
*/
|
|
60
|
+
export function createAskUserToolHandler(): ToolHandler<AskUserToolInput, AskUserToolResult> {
|
|
61
|
+
return {
|
|
62
|
+
descriptor: {
|
|
63
|
+
name: 'ask-user',
|
|
64
|
+
title: '询问用户',
|
|
65
|
+
description: [
|
|
66
|
+
'当分析需要用户补充判断或选择下一步方向时使用。',
|
|
67
|
+
'问题应具体可执行;如果存在明确选项,应通过 options 传入。',
|
|
68
|
+
].join('\n'),
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ['question'],
|
|
73
|
+
properties: {
|
|
74
|
+
question: { type: 'string', minLength: 1, description: '要询问用户的问题。' },
|
|
75
|
+
options: { type: 'array', items: { type: 'string', minLength: 1 }, description: '可选答案列表。' },
|
|
76
|
+
multiSelect: { type: 'boolean', description: '用户是否可以选择多个答案。' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
resultSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
required: ['answer'],
|
|
83
|
+
properties: {
|
|
84
|
+
answer: {
|
|
85
|
+
description: '用户回答,供模型继续推理。',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
annotations: {
|
|
90
|
+
readOnly: true,
|
|
91
|
+
idempotent: false,
|
|
92
|
+
openWorld: true,
|
|
93
|
+
destructive: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
inputSchema: askUserToolInputSchema,
|
|
97
|
+
resultSchema: askUserToolResultSchema,
|
|
98
|
+
execute: async (input): Promise<ToolExecution<AskUserToolResult>> => ({
|
|
99
|
+
status: 'paused',
|
|
100
|
+
effects: [
|
|
101
|
+
{
|
|
102
|
+
type: 'agent.action.request',
|
|
103
|
+
payload: createAskUserActionPayload(input),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
resume: async ({ resolvedEvent }) => ({
|
|
108
|
+
status: 'completed',
|
|
109
|
+
modelResult: askUserToolResultSchema.parse({
|
|
110
|
+
answer: resolveAskUserAnswer(resolvedEvent.payload.result),
|
|
111
|
+
}),
|
|
112
|
+
effects: [],
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ask-user.js';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolDescriptor,
|
|
3
|
+
ToolHandler,
|
|
4
|
+
ToolRegistry,
|
|
5
|
+
} from '@ct-agents/protocol';
|
|
6
|
+
|
|
7
|
+
export class ToolNotFoundError extends Error {
|
|
8
|
+
constructor(toolName: string) {
|
|
9
|
+
super(`Tool not found: ${toolName}`);
|
|
10
|
+
this.name = 'ToolNotFoundError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class InMemoryToolRegistry implements ToolRegistry {
|
|
15
|
+
private readonly handlers = new Map<string, ToolHandler<unknown, unknown>>();
|
|
16
|
+
|
|
17
|
+
constructor(handlers: ToolHandler[]) {
|
|
18
|
+
for (const handler of handlers) {
|
|
19
|
+
const name = handler.descriptor.name;
|
|
20
|
+
if (this.handlers.has(name)) {
|
|
21
|
+
throw new Error(`Duplicate tool name: ${name}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.handlers.set(name, handler as ToolHandler<unknown, unknown>);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getTool(toolName: string) {
|
|
29
|
+
const handler = this.handlers.get(toolName);
|
|
30
|
+
if (!handler) {
|
|
31
|
+
throw new ToolNotFoundError(toolName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return handler;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
listTools(): ToolDescriptor[] {
|
|
38
|
+
return Array.from(this.handlers.values())
|
|
39
|
+
.map((handler) => handler.descriptor)
|
|
40
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CompositeToolRegistry implements ToolRegistry {
|
|
45
|
+
private readonly extra = new Map<string, ToolHandler<unknown, unknown>>();
|
|
46
|
+
|
|
47
|
+
constructor(private readonly base: ToolRegistry) {}
|
|
48
|
+
|
|
49
|
+
addTool(handler: ToolHandler) {
|
|
50
|
+
const name = handler.descriptor.name;
|
|
51
|
+
if (this.extra.has(name)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.extra.set(name, handler as ToolHandler<unknown, unknown>);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getTool(toolName: string) {
|
|
59
|
+
const extraHandler = this.extra.get(toolName);
|
|
60
|
+
if (extraHandler) {
|
|
61
|
+
return extraHandler;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this.base.getTool(toolName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
listTools() {
|
|
68
|
+
return [
|
|
69
|
+
...this.base.listTools(),
|
|
70
|
+
...Array.from(this.extra.values()).map((handler) => handler.descriptor),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export * from './sandbox-tools/index.js';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentActionRequestedPayload,
|
|
3
|
+
ToolExecution,
|
|
4
|
+
ToolResumeInput,
|
|
5
|
+
} from '@ct-agents/protocol';
|
|
6
|
+
|
|
7
|
+
export const TOOL_APPROVAL_DENIED = 'TOOL_APPROVAL_DENIED';
|
|
8
|
+
export const APPROVAL_ALLOWED = '批准';
|
|
9
|
+
export const APPROVAL_DENIED = '拒绝';
|
|
10
|
+
|
|
11
|
+
export type ApprovalOptions = {
|
|
12
|
+
requireApproval?: boolean;
|
|
13
|
+
allowedCommands?: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ApprovalRequestInput = {
|
|
17
|
+
subject: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function needsApproval(input: {
|
|
22
|
+
requireApproval: boolean;
|
|
23
|
+
commandName?: string | null;
|
|
24
|
+
command?: string;
|
|
25
|
+
allowedCommands?: ReadonlySet<string>;
|
|
26
|
+
}): boolean {
|
|
27
|
+
if (!input.requireApproval) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!input.commandName || !input.allowedCommands || input.allowedCommands.size === 0) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const normalizedCommandName = normalizeCommandName(input.commandName);
|
|
36
|
+
return isShellMetaCommand(normalizedCommandName)
|
|
37
|
+
|| !input.allowedCommands.has(normalizedCommandName)
|
|
38
|
+
|| hasShellControlOperator(input.command ?? input.commandName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pausedForApproval(input: ApprovalRequestInput): ToolExecution<never> {
|
|
42
|
+
return {
|
|
43
|
+
status: 'paused',
|
|
44
|
+
effects: [
|
|
45
|
+
{
|
|
46
|
+
type: 'agent.action.request',
|
|
47
|
+
payload: createApprovalPayload(input),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function approvalDenied<TResult>(): ToolExecution<TResult> {
|
|
54
|
+
return {
|
|
55
|
+
status: 'failed',
|
|
56
|
+
error: {
|
|
57
|
+
code: TOOL_APPROVAL_DENIED,
|
|
58
|
+
message: '用户拒绝执行该工具操作',
|
|
59
|
+
retryable: false,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isApprovalGranted(input: ToolResumeInput<unknown>): boolean {
|
|
65
|
+
const result = input.resolvedEvent.payload.result;
|
|
66
|
+
if (typeof result === 'string') {
|
|
67
|
+
return result === APPROVAL_ALLOWED;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (isRecord(result)) {
|
|
71
|
+
const answer = result.answer;
|
|
72
|
+
return answer === APPROVAL_ALLOWED || answer === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result === true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createApprovalPayload(input: ApprovalRequestInput): AgentActionRequestedPayload {
|
|
79
|
+
return {
|
|
80
|
+
kind: 'ask_user',
|
|
81
|
+
prompt: `是否批准执行 ${input.subject}?\n${input.summary}`,
|
|
82
|
+
input: {
|
|
83
|
+
subject: input.subject,
|
|
84
|
+
summary: input.summary,
|
|
85
|
+
},
|
|
86
|
+
allowedResults: [APPROVAL_ALLOWED, APPROVAL_DENIED],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
91
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasShellControlOperator(command: string): boolean {
|
|
95
|
+
return /[;&|<>`\r\n]|[$][(]/.test(command);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeCommandName(commandName: string): string {
|
|
99
|
+
const normalized = commandName.toLowerCase().replace(/\\/g, '/');
|
|
100
|
+
return normalized.split('/').filter(Boolean).at(-1) ?? normalized;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isShellMetaCommand(commandName: string): boolean {
|
|
104
|
+
return commandName === '.'
|
|
105
|
+
|| commandName === 'eval'
|
|
106
|
+
|| commandName === 'exec'
|
|
107
|
+
|| commandName === 'source'
|
|
108
|
+
|| commandName === 'sh'
|
|
109
|
+
|| commandName === 'bash'
|
|
110
|
+
|| commandName === 'node'
|
|
111
|
+
|| commandName === 'python'
|
|
112
|
+
|| commandName === 'python3'
|
|
113
|
+
|| commandName === 'perl'
|
|
114
|
+
|| commandName === 'ruby'
|
|
115
|
+
|| commandName === 'php'
|
|
116
|
+
|| commandName === 'lua'
|
|
117
|
+
|| commandName === 'busybox'
|
|
118
|
+
|| commandName === 'env'
|
|
119
|
+
|| commandName === 'xargs';
|
|
120
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
ExecResult,
|
|
4
|
+
ToolExecution,
|
|
5
|
+
ToolHandler,
|
|
6
|
+
ToolHandlerContext,
|
|
7
|
+
} from '@ct-agents/protocol';
|
|
8
|
+
import {
|
|
9
|
+
approvalDenied,
|
|
10
|
+
isApprovalGranted,
|
|
11
|
+
needsApproval,
|
|
12
|
+
pausedForApproval,
|
|
13
|
+
} from './approval.js';
|
|
14
|
+
import { truncateText } from './truncate.js';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_TEXT_MAX_BYTES,
|
|
17
|
+
HARD_MAX_TEXT_BYTES,
|
|
18
|
+
HARD_MAX_TIMEOUT_MS,
|
|
19
|
+
failed,
|
|
20
|
+
normalizeLimit,
|
|
21
|
+
requireSandbox,
|
|
22
|
+
} from './shared.js';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
export type BashToolOptions = {
|
|
27
|
+
defaultTimeoutMs?: number;
|
|
28
|
+
defaultMaxBytes?: number;
|
|
29
|
+
deniedCommands?: readonly string[];
|
|
30
|
+
requireApproval?: boolean;
|
|
31
|
+
allowedCommands?: readonly string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const bashInputSchema = z.object({
|
|
35
|
+
command: z.string().trim().min(1, 'command 为必填字段'),
|
|
36
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
37
|
+
maxBytes: z.number().int().positive().optional(),
|
|
38
|
+
}).strict();
|
|
39
|
+
|
|
40
|
+
export type BashInput = z.infer<typeof bashInputSchema>;
|
|
41
|
+
|
|
42
|
+
export type BashResult = {
|
|
43
|
+
stdout: string;
|
|
44
|
+
stderr: string;
|
|
45
|
+
exitCode: number;
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
totalStdoutBytes?: number;
|
|
49
|
+
totalStderrBytes?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const bashResultSchema = z.object({
|
|
53
|
+
stdout: z.string(),
|
|
54
|
+
stderr: z.string(),
|
|
55
|
+
exitCode: z.number().int(),
|
|
56
|
+
timedOut: z.boolean(),
|
|
57
|
+
truncated: z.boolean(),
|
|
58
|
+
totalStdoutBytes: z.number().int().nonnegative().optional(),
|
|
59
|
+
totalStderrBytes: z.number().int().nonnegative().optional(),
|
|
60
|
+
}).strict() as z.ZodType<BashResult>;
|
|
61
|
+
|
|
62
|
+
export function createBashToolHandler(options: BashToolOptions = {}): ToolHandler<BashInput, BashResult> {
|
|
63
|
+
const defaultTimeoutMs = normalizeLimit(options.defaultTimeoutMs, DEFAULT_TIMEOUT_MS, HARD_MAX_TIMEOUT_MS);
|
|
64
|
+
const defaultMaxBytes = normalizeLimit(options.defaultMaxBytes, DEFAULT_TEXT_MAX_BYTES, HARD_MAX_TEXT_BYTES);
|
|
65
|
+
const deniedCommands = new Set((options.deniedCommands ?? []).map(normalizeCommandName));
|
|
66
|
+
const allowedCommands = new Set((options.allowedCommands ?? []).map(normalizeCommandName));
|
|
67
|
+
const requireApproval = options.requireApproval ?? true;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
descriptor: {
|
|
71
|
+
name: 'bash',
|
|
72
|
+
title: '执行沙箱命令',
|
|
73
|
+
description: '在会话沙箱内执行 shell 命令,并返回截断后的 stdout/stderr。',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
required: ['command'],
|
|
78
|
+
properties: {
|
|
79
|
+
command: { type: 'string', minLength: 1, description: '要执行的 shell 命令。' },
|
|
80
|
+
timeoutMs: { type: 'integer', minimum: 1, description: '命令超时时间。' },
|
|
81
|
+
maxBytes: { type: 'integer', minimum: 1, description: 'stdout/stderr 各自最大返回字节数。' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
resultSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
required: ['stdout', 'stderr', 'exitCode', 'timedOut', 'truncated'],
|
|
88
|
+
properties: {
|
|
89
|
+
stdout: { type: 'string' },
|
|
90
|
+
stderr: { type: 'string' },
|
|
91
|
+
exitCode: { type: 'integer' },
|
|
92
|
+
timedOut: { type: 'boolean' },
|
|
93
|
+
truncated: { type: 'boolean' },
|
|
94
|
+
totalStdoutBytes: { type: 'integer', minimum: 0 },
|
|
95
|
+
totalStderrBytes: { type: 'integer', minimum: 0 },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
requiredResources: ['sandbox'],
|
|
99
|
+
annotations: {
|
|
100
|
+
readOnly: false,
|
|
101
|
+
idempotent: false,
|
|
102
|
+
openWorld: true,
|
|
103
|
+
destructive: true,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
inputSchema: bashInputSchema,
|
|
107
|
+
resultSchema: bashResultSchema,
|
|
108
|
+
execute: async (input, context): Promise<ToolExecution<BashResult>> => {
|
|
109
|
+
const commandName = parseCommandName(input.command);
|
|
110
|
+
const deniedCommand = resolveDeniedCommandName(input.command, deniedCommands);
|
|
111
|
+
if (deniedCommand) {
|
|
112
|
+
return failed('BASH_COMMAND_DENIED', `命令被拒绝执行:${deniedCommand}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (needsApproval({
|
|
116
|
+
requireApproval,
|
|
117
|
+
commandName,
|
|
118
|
+
command: input.command,
|
|
119
|
+
allowedCommands,
|
|
120
|
+
})) {
|
|
121
|
+
return pausedForApproval({
|
|
122
|
+
subject: 'bash',
|
|
123
|
+
summary: input.command,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return executeBash(input, context, { defaultTimeoutMs, defaultMaxBytes, requireApproval });
|
|
128
|
+
},
|
|
129
|
+
resume: async (input, context) => {
|
|
130
|
+
const commandName = parseCommandName(input.originalInput.command);
|
|
131
|
+
const deniedCommand = resolveDeniedCommandName(input.originalInput.command, deniedCommands);
|
|
132
|
+
if (deniedCommand) {
|
|
133
|
+
return failed('BASH_COMMAND_DENIED', `命令被拒绝执行:${deniedCommand}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!isApprovalGranted(input)) {
|
|
137
|
+
return approvalDenied();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return executeBash(input.originalInput, context, { defaultTimeoutMs, defaultMaxBytes, requireApproval });
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function executeBash(
|
|
146
|
+
input: BashInput,
|
|
147
|
+
context: ToolHandlerContext,
|
|
148
|
+
defaults: { defaultTimeoutMs: number; defaultMaxBytes: number; requireApproval: boolean },
|
|
149
|
+
): Promise<ToolExecution<BashResult>> {
|
|
150
|
+
const sandbox = requireSandbox(context);
|
|
151
|
+
if (isToolExecution(sandbox)) {
|
|
152
|
+
return sandbox;
|
|
153
|
+
}
|
|
154
|
+
if (!defaults.requireApproval && !sandbox.isolated) {
|
|
155
|
+
return failed('BASH_SANDBOX_NOT_ISOLATED', 'requireApproval=false 时必须使用隔离 sandbox');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const timeoutMs = normalizeLimit(input.timeoutMs, defaults.defaultTimeoutMs, HARD_MAX_TIMEOUT_MS);
|
|
160
|
+
const maxBytes = normalizeLimit(input.maxBytes, defaults.defaultMaxBytes, HARD_MAX_TEXT_BYTES);
|
|
161
|
+
const result = await sandbox.exec(input.command, {
|
|
162
|
+
timeoutMs,
|
|
163
|
+
maxOutputBytes: maxBytes,
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
status: 'completed',
|
|
167
|
+
modelResult: truncateExecResult(result, maxBytes),
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return failed('BASH_EXEC_FAILED', error instanceof Error ? error.message : String(error));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function truncateExecResult(result: ExecResult, maxBytes: number): BashResult {
|
|
175
|
+
const stdout = truncateText(result.stdout, maxBytes);
|
|
176
|
+
const stderr = truncateText(result.stderr, maxBytes);
|
|
177
|
+
const stdoutTruncated = stdout.truncated || result.stdoutTruncated === true;
|
|
178
|
+
const stderrTruncated = stderr.truncated || result.stderrTruncated === true;
|
|
179
|
+
return {
|
|
180
|
+
stdout: stdout.text,
|
|
181
|
+
stderr: stderr.text,
|
|
182
|
+
exitCode: result.exitCode,
|
|
183
|
+
timedOut: result.timedOut,
|
|
184
|
+
truncated: stdoutTruncated || stderrTruncated,
|
|
185
|
+
...(stdout.totalBytes !== undefined || result.totalStdoutBytes !== undefined
|
|
186
|
+
? { totalStdoutBytes: result.totalStdoutBytes ?? stdout.totalBytes }
|
|
187
|
+
: {}),
|
|
188
|
+
...(stderr.totalBytes !== undefined || result.totalStderrBytes !== undefined
|
|
189
|
+
? { totalStderrBytes: result.totalStderrBytes ?? stderr.totalBytes }
|
|
190
|
+
: {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseCommandName(command: string): string | null {
|
|
195
|
+
const tokens = command.trim().match(/[^\s;&|()<>]+/g) ?? [];
|
|
196
|
+
for (const token of tokens) {
|
|
197
|
+
const cleaned = token.replace(/^["']|["']$/g, '');
|
|
198
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(cleaned)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
return normalizeCommandName(cleaned);
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveDeniedCommandName(command: string, deniedCommands: ReadonlySet<string>): string | null {
|
|
207
|
+
const commandName = parseCommandName(command);
|
|
208
|
+
if (commandName && deniedCommands.has(commandName)) {
|
|
209
|
+
return commandName;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (deniedCommands.size > 0 && hasShellJoinedCommandToken(command)) {
|
|
213
|
+
return commandName ?? 'shell-joined-command';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function hasShellJoinedCommandToken(command: string): boolean {
|
|
220
|
+
const firstToken = command.trim().match(/^([^\s;&|()<>]+)/)?.[1];
|
|
221
|
+
return Boolean(firstToken && /["'\\$]/.test(firstToken));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeCommandName(commandName: string): string {
|
|
225
|
+
const normalized = commandName.toLowerCase().replace(/\\/g, '/');
|
|
226
|
+
return normalized.split('/').filter(Boolean).at(-1) ?? normalized;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isToolExecution<T>(value: unknown): value is ToolExecution<T> {
|
|
230
|
+
return value !== null && typeof value === 'object' && 'status' in value;
|
|
231
|
+
}
|