@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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
ToolExecution,
|
|
4
|
+
ToolHandler,
|
|
5
|
+
ToolHandlerContext,
|
|
6
|
+
} from '@ct-agents/protocol';
|
|
7
|
+
import {
|
|
8
|
+
approvalDenied,
|
|
9
|
+
isApprovalGranted,
|
|
10
|
+
pausedForApproval,
|
|
11
|
+
} from './approval.js';
|
|
12
|
+
import { truncateRows } from './truncate.js';
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_MAX_ROWS,
|
|
15
|
+
DEFAULT_TEXT_MAX_BYTES,
|
|
16
|
+
HARD_MAX_ROWS,
|
|
17
|
+
HARD_MAX_TEXT_BYTES,
|
|
18
|
+
failed,
|
|
19
|
+
normalizeBoolean,
|
|
20
|
+
normalizeLimit,
|
|
21
|
+
} from './shared.js';
|
|
22
|
+
import {
|
|
23
|
+
containsDangerousPostgresCapability,
|
|
24
|
+
containsDdlDclOrMaintenanceKeyword,
|
|
25
|
+
scanSqlStructure,
|
|
26
|
+
} from './sql-guard.js';
|
|
27
|
+
|
|
28
|
+
export type DbExecuteToolOptions = {
|
|
29
|
+
allowWrite?: boolean;
|
|
30
|
+
defaultMaxRows?: number;
|
|
31
|
+
defaultMaxBytes?: number;
|
|
32
|
+
requireApproval?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const dbExecuteInputSchema = z.object({
|
|
36
|
+
sql: z.string().trim().min(1, 'sql 为必填字段'),
|
|
37
|
+
params: z.array(z.unknown()).optional(),
|
|
38
|
+
maxRows: z.number().int().positive().optional(),
|
|
39
|
+
maxBytes: z.number().int().positive().optional(),
|
|
40
|
+
}).strict();
|
|
41
|
+
|
|
42
|
+
export type DbExecuteInput = z.infer<typeof dbExecuteInputSchema>;
|
|
43
|
+
|
|
44
|
+
export type DbExecuteResult = {
|
|
45
|
+
rows: unknown[];
|
|
46
|
+
rowCount: number;
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
totalRows?: number;
|
|
49
|
+
totalBytes?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const dbExecuteResultSchema = z.object({
|
|
53
|
+
rows: z.array(z.unknown()),
|
|
54
|
+
rowCount: z.number().int().nonnegative(),
|
|
55
|
+
truncated: z.boolean(),
|
|
56
|
+
totalRows: z.number().int().nonnegative().optional(),
|
|
57
|
+
totalBytes: z.number().int().nonnegative().optional(),
|
|
58
|
+
}).strict() as z.ZodType<DbExecuteResult>;
|
|
59
|
+
|
|
60
|
+
type QueryLikeResult = {
|
|
61
|
+
rows?: unknown;
|
|
62
|
+
rowCount?: unknown;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function createDbExecuteToolHandler(options: DbExecuteToolOptions = {}): ToolHandler<DbExecuteInput, DbExecuteResult> {
|
|
66
|
+
const allowWrite = normalizeBoolean(options.allowWrite, false);
|
|
67
|
+
const defaultMaxRows = normalizeLimit(options.defaultMaxRows, DEFAULT_MAX_ROWS, HARD_MAX_ROWS);
|
|
68
|
+
const defaultMaxBytes = normalizeLimit(options.defaultMaxBytes, DEFAULT_TEXT_MAX_BYTES, HARD_MAX_TEXT_BYTES);
|
|
69
|
+
const requireApproval = options.requireApproval ?? true;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
descriptor: {
|
|
73
|
+
name: 'db-execute',
|
|
74
|
+
title: '执行数据库写入',
|
|
75
|
+
description: '执行数据库变更 SQL,并返回截断后的结果集。',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
required: ['sql'],
|
|
80
|
+
properties: {
|
|
81
|
+
sql: { type: 'string', minLength: 1, description: '要执行的 SQL。' },
|
|
82
|
+
params: { type: 'array', description: 'SQL 参数。' },
|
|
83
|
+
maxRows: { type: 'integer', minimum: 1, description: '最多返回行数。' },
|
|
84
|
+
maxBytes: { type: 'integer', minimum: 1, description: '结果 JSON 的最大字节数。' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
resultSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
required: ['rows', 'rowCount', 'truncated'],
|
|
91
|
+
properties: {
|
|
92
|
+
rows: { type: 'array', items: {} },
|
|
93
|
+
rowCount: { type: 'integer', minimum: 0 },
|
|
94
|
+
truncated: { type: 'boolean' },
|
|
95
|
+
totalRows: { type: 'integer', minimum: 0 },
|
|
96
|
+
totalBytes: { type: 'integer', minimum: 0 },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
requiredResources: ['database'],
|
|
100
|
+
annotations: {
|
|
101
|
+
readOnly: false,
|
|
102
|
+
idempotent: false,
|
|
103
|
+
openWorld: false,
|
|
104
|
+
destructive: true,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
inputSchema: dbExecuteInputSchema,
|
|
108
|
+
resultSchema: dbExecuteResultSchema,
|
|
109
|
+
execute: async (input, context): Promise<ToolExecution<DbExecuteResult>> => {
|
|
110
|
+
if (!allowWrite) {
|
|
111
|
+
return failed('DB_EXECUTE_WRITE_DISABLED', '当前 database resource 未允许写 SQL');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (requireApproval) {
|
|
115
|
+
return pausedForApproval({
|
|
116
|
+
subject: 'db-execute',
|
|
117
|
+
summary: input.sql,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return executeDb(input, context, { defaultMaxRows, defaultMaxBytes });
|
|
122
|
+
},
|
|
123
|
+
resume: async (input, context) => {
|
|
124
|
+
if (!allowWrite) {
|
|
125
|
+
return failed('DB_EXECUTE_WRITE_DISABLED', '当前 database resource 未允许写 SQL');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isApprovalGranted(input)) {
|
|
129
|
+
return approvalDenied();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return executeDb(input.originalInput, context, { defaultMaxRows, defaultMaxBytes });
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function executeDb(
|
|
138
|
+
input: DbExecuteInput,
|
|
139
|
+
context: ToolHandlerContext,
|
|
140
|
+
defaults: { defaultMaxRows: number; defaultMaxBytes: number },
|
|
141
|
+
): Promise<ToolExecution<DbExecuteResult>> {
|
|
142
|
+
const database = context.resources.database;
|
|
143
|
+
if (!database) {
|
|
144
|
+
return failed('DATABASE_RESOURCE_MISSING', '当前工具上下文缺少 database resource');
|
|
145
|
+
}
|
|
146
|
+
if (!isAllowedWriteSql(input.sql)) {
|
|
147
|
+
return failed('DB_EXECUTE_SQL_NOT_ALLOWED', 'db-execute 只允许单条数据变更 SQL,拒绝 DDL/DCL、维护命令、多语句和高危 PostgreSQL 能力');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const maxRows = normalizeLimit(input.maxRows, defaults.defaultMaxRows, HARD_MAX_ROWS);
|
|
152
|
+
const queryResult = await database.query(input.sql, input.params, { maxRows: maxRows + 1 });
|
|
153
|
+
return {
|
|
154
|
+
status: 'completed',
|
|
155
|
+
modelResult: normalizeQueryResult(queryResult, input, defaults),
|
|
156
|
+
};
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return failed('DB_EXECUTE_FAILED', error instanceof Error ? error.message : String(error));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isAllowedWriteSql(sql: string): boolean {
|
|
163
|
+
const scan = scanSqlStructure(sql);
|
|
164
|
+
const normalized = scan.structureSql.trim();
|
|
165
|
+
if (!normalized || scan.hasStatementSeparator) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (containsDdlDclOrMaintenanceKeyword(normalized) || containsDangerousPostgresCapability(normalized)) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const firstToken = normalized.match(/^[a-z]+/i)?.[0].toLowerCase();
|
|
173
|
+
if (firstToken === 'insert' || firstToken === 'update' || firstToken === 'delete' || firstToken === 'merge') {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (firstToken !== 'with') {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return /\)\s*(insert|update|delete|merge)\b/i.test(normalized);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeQueryResult(
|
|
184
|
+
queryResult: unknown,
|
|
185
|
+
input: DbExecuteInput,
|
|
186
|
+
defaults: { defaultMaxRows: number; defaultMaxBytes: number },
|
|
187
|
+
): DbExecuteResult {
|
|
188
|
+
const result = isRecord(queryResult) ? queryResult as QueryLikeResult : {};
|
|
189
|
+
const sourceRows = Array.isArray(result.rows) ? result.rows : [];
|
|
190
|
+
const rowCount = typeof result.rowCount === 'number' && Number.isFinite(result.rowCount)
|
|
191
|
+
? Math.max(0, Math.trunc(result.rowCount))
|
|
192
|
+
: sourceRows.length;
|
|
193
|
+
const maxRows = normalizeLimit(input.maxRows, defaults.defaultMaxRows, HARD_MAX_ROWS);
|
|
194
|
+
const maxBytes = normalizeLimit(input.maxBytes, defaults.defaultMaxBytes, HARD_MAX_TEXT_BYTES);
|
|
195
|
+
const rows = truncateRows(sourceRows, maxRows);
|
|
196
|
+
const byteLimited = truncateResultBytes(rows.rows, maxBytes);
|
|
197
|
+
return {
|
|
198
|
+
rows: byteLimited.rows,
|
|
199
|
+
rowCount,
|
|
200
|
+
truncated: rows.truncated || byteLimited.truncated,
|
|
201
|
+
...(rows.totalRows !== undefined ? { totalRows: rows.totalRows } : {}),
|
|
202
|
+
...(byteLimited.totalBytes !== undefined ? { totalBytes: byteLimited.totalBytes } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function truncateResultBytes(rows: unknown[], maxBytes: number): { rows: unknown[]; truncated: boolean; totalBytes?: number } {
|
|
207
|
+
const encoded = new TextEncoder().encode(JSON.stringify(rows));
|
|
208
|
+
if (encoded.byteLength <= maxBytes) {
|
|
209
|
+
return { rows, truncated: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const limitedRows: unknown[] = [];
|
|
213
|
+
for (const row of rows) {
|
|
214
|
+
const candidate = [...limitedRows, row];
|
|
215
|
+
const candidateBytes = new TextEncoder().encode(JSON.stringify(candidate)).byteLength;
|
|
216
|
+
if (candidateBytes > maxBytes) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
limitedRows.push(row);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
rows: limitedRows,
|
|
224
|
+
truncated: true,
|
|
225
|
+
totalBytes: encoded.byteLength,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
230
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
231
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
Database,
|
|
4
|
+
ToolExecution,
|
|
5
|
+
ToolHandler,
|
|
6
|
+
} from '@ct-agents/protocol';
|
|
7
|
+
import {
|
|
8
|
+
HARD_MAX_ROWS,
|
|
9
|
+
HARD_MAX_TEXT_BYTES,
|
|
10
|
+
normalizeLimit,
|
|
11
|
+
} from './shared.js';
|
|
12
|
+
import {
|
|
13
|
+
containsDangerousPostgresCapability,
|
|
14
|
+
containsWriteOrDdlKeyword,
|
|
15
|
+
scanSqlStructure,
|
|
16
|
+
} from './sql-guard.js';
|
|
17
|
+
import { truncateRows } from './truncate.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_ROWS = 100;
|
|
20
|
+
const DEFAULT_MAX_BYTES = 128_000;
|
|
21
|
+
|
|
22
|
+
export type DbQueryToolOptions = {
|
|
23
|
+
defaultMaxRows?: number;
|
|
24
|
+
defaultMaxBytes?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const dbQueryInputSchema = z.object({
|
|
28
|
+
sql: z.string().trim().min(1, 'sql 为必填字段'),
|
|
29
|
+
params: z.array(z.unknown()).optional(),
|
|
30
|
+
maxRows: z.number().int().positive().optional(),
|
|
31
|
+
maxBytes: z.number().int().positive().optional(),
|
|
32
|
+
}).strict();
|
|
33
|
+
|
|
34
|
+
export type DbQueryInput = z.infer<typeof dbQueryInputSchema>;
|
|
35
|
+
|
|
36
|
+
export type DbQueryResult = {
|
|
37
|
+
rows: unknown[];
|
|
38
|
+
rowCount: number;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
totalRows?: number;
|
|
41
|
+
totalBytes?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const dbQueryResultSchema = z.object({
|
|
45
|
+
rows: z.array(z.unknown()),
|
|
46
|
+
rowCount: z.number().int().nonnegative(),
|
|
47
|
+
truncated: z.boolean(),
|
|
48
|
+
totalRows: z.number().int().nonnegative().optional(),
|
|
49
|
+
totalBytes: z.number().int().nonnegative().optional(),
|
|
50
|
+
}).strict() as z.ZodType<DbQueryResult>;
|
|
51
|
+
|
|
52
|
+
type QueryLikeResult = {
|
|
53
|
+
rows?: unknown;
|
|
54
|
+
rowCount?: unknown;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function createDbQueryToolHandler(options: DbQueryToolOptions = {}): ToolHandler<DbQueryInput, DbQueryResult> {
|
|
58
|
+
const defaultMaxRows = normalizeLimit(options.defaultMaxRows, DEFAULT_MAX_ROWS, HARD_MAX_ROWS);
|
|
59
|
+
const defaultMaxBytes = normalizeLimit(options.defaultMaxBytes, DEFAULT_MAX_BYTES, HARD_MAX_TEXT_BYTES);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
descriptor: {
|
|
63
|
+
name: 'db-query',
|
|
64
|
+
title: '查询数据库',
|
|
65
|
+
description: '执行只读 SQL 查询并返回截断后的结果集。database resource 必须使用最小权限只读账号。',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
required: ['sql'],
|
|
70
|
+
properties: {
|
|
71
|
+
sql: { type: 'string', minLength: 1, description: '只读 SQL;仅允许 SELECT 或 WITH ... SELECT。' },
|
|
72
|
+
params: { type: 'array', description: '查询参数。' },
|
|
73
|
+
maxRows: { type: 'integer', minimum: 1, description: '最多返回行数。' },
|
|
74
|
+
maxBytes: { type: 'integer', minimum: 1, description: '结果 JSON 的最大字节数。' },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
resultSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
required: ['rows', 'rowCount', 'truncated'],
|
|
81
|
+
properties: {
|
|
82
|
+
rows: { type: 'array', items: {} },
|
|
83
|
+
rowCount: { type: 'integer', minimum: 0 },
|
|
84
|
+
truncated: { type: 'boolean' },
|
|
85
|
+
totalRows: { type: 'integer', minimum: 0 },
|
|
86
|
+
totalBytes: { type: 'integer', minimum: 0 },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
requiredResources: ['database'],
|
|
90
|
+
annotations: {
|
|
91
|
+
readOnly: true,
|
|
92
|
+
idempotent: true,
|
|
93
|
+
openWorld: false,
|
|
94
|
+
destructive: false,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
inputSchema: dbQueryInputSchema,
|
|
98
|
+
resultSchema: dbQueryResultSchema,
|
|
99
|
+
execute: async (input, context): Promise<ToolExecution<DbQueryResult>> => {
|
|
100
|
+
if (!isReadOnlySql(input.sql)) {
|
|
101
|
+
return failed('DB_QUERY_NOT_READ_ONLY', 'db-query 只允许执行 SELECT 或 WITH ... SELECT 查询');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const database = context.resources.database;
|
|
105
|
+
if (!database) {
|
|
106
|
+
return failed('DATABASE_RESOURCE_MISSING', '当前工具上下文缺少 database resource');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const maxRows = normalizeLimit(input.maxRows, defaultMaxRows, HARD_MAX_ROWS);
|
|
111
|
+
const queryResult = await database.query(input.sql, input.params, { readOnly: true, maxRows: maxRows + 1 });
|
|
112
|
+
const modelResult = normalizeQueryResult(queryResult, input, {
|
|
113
|
+
defaultMaxRows,
|
|
114
|
+
defaultMaxBytes,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
status: 'completed',
|
|
118
|
+
modelResult,
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return failed('DB_QUERY_FAILED', error instanceof Error ? error.message : String(error));
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeQueryResult(
|
|
128
|
+
queryResult: unknown,
|
|
129
|
+
input: DbQueryInput,
|
|
130
|
+
defaults: { defaultMaxRows: number; defaultMaxBytes: number },
|
|
131
|
+
): DbQueryResult {
|
|
132
|
+
const result = isRecord(queryResult) ? queryResult as QueryLikeResult : {};
|
|
133
|
+
const sourceRows = Array.isArray(result.rows) ? result.rows : [];
|
|
134
|
+
const rowCount = typeof result.rowCount === 'number' && Number.isFinite(result.rowCount)
|
|
135
|
+
? Math.max(0, Math.trunc(result.rowCount))
|
|
136
|
+
: sourceRows.length;
|
|
137
|
+
const maxRows = normalizeLimit(input.maxRows, defaults.defaultMaxRows, HARD_MAX_ROWS);
|
|
138
|
+
const maxBytes = normalizeLimit(input.maxBytes, defaults.defaultMaxBytes, HARD_MAX_TEXT_BYTES);
|
|
139
|
+
const rows = truncateRows(sourceRows, maxRows);
|
|
140
|
+
const byteLimited = truncateResultBytes(rows.rows, maxBytes);
|
|
141
|
+
const truncated = rows.truncated || byteLimited.truncated;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
rows: byteLimited.rows,
|
|
145
|
+
rowCount,
|
|
146
|
+
truncated,
|
|
147
|
+
...(rows.truncated ? { totalRows: sourceRows.length } : {}),
|
|
148
|
+
...(byteLimited.truncated ? { totalBytes: byteLimited.totalBytes } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function truncateResultBytes(rows: unknown[], maxBytes: number): { rows: unknown[]; truncated: boolean; totalBytes?: number } {
|
|
153
|
+
const encoded = new TextEncoder().encode(JSON.stringify(rows));
|
|
154
|
+
if (encoded.byteLength <= maxBytes) {
|
|
155
|
+
return { rows, truncated: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const limitedRows: unknown[] = [];
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const candidate = [...limitedRows, row];
|
|
161
|
+
const candidateBytes = new TextEncoder().encode(JSON.stringify(candidate)).byteLength;
|
|
162
|
+
if (candidateBytes > maxBytes) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
limitedRows.push(row);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
rows: limitedRows,
|
|
170
|
+
truncated: true,
|
|
171
|
+
totalBytes: encoded.byteLength,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isReadOnlySql(sql: string): boolean {
|
|
176
|
+
const scan = scanSqlStructure(sql);
|
|
177
|
+
const normalized = scan.structureSql.trim();
|
|
178
|
+
if (!normalized || scan.hasStatementSeparator) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (containsWriteOrDdlKeyword(normalized) || containsDangerousPostgresCapability(normalized)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const firstToken = normalized.match(/^[a-z]+/i)?.[0].toLowerCase();
|
|
187
|
+
if (firstToken === 'select') {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (firstToken !== 'with') {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return /\)\s*select\b/i.test(normalized);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function failed(code: string, message: string): ToolExecution<DbQueryResult> {
|
|
199
|
+
return {
|
|
200
|
+
status: 'failed',
|
|
201
|
+
error: {
|
|
202
|
+
code,
|
|
203
|
+
message,
|
|
204
|
+
retryable: false,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
210
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
211
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
ToolExecution,
|
|
4
|
+
ToolHandler,
|
|
5
|
+
} from '@ct-agents/protocol';
|
|
6
|
+
import { truncateText } from './truncate.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_TEXT_MAX_BYTES,
|
|
9
|
+
HARD_MAX_TEXT_BYTES,
|
|
10
|
+
failed,
|
|
11
|
+
normalizeLimit,
|
|
12
|
+
normalizePathOrFailed,
|
|
13
|
+
requireSandbox,
|
|
14
|
+
} from './shared.js';
|
|
15
|
+
|
|
16
|
+
export type FsReadToolOptions = {
|
|
17
|
+
defaultMaxBytes?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const UTF8_READ_AHEAD_BYTES = 4;
|
|
21
|
+
|
|
22
|
+
export const fsReadInputSchema = z.object({
|
|
23
|
+
path: z.string().trim().min(1, 'path 为必填字段'),
|
|
24
|
+
offset: z.number().int().nonnegative().optional(),
|
|
25
|
+
limit: z.number().int().positive().optional(),
|
|
26
|
+
maxBytes: z.number().int().positive().optional(),
|
|
27
|
+
}).strict();
|
|
28
|
+
|
|
29
|
+
export type FsReadInput = z.infer<typeof fsReadInputSchema>;
|
|
30
|
+
|
|
31
|
+
export type FsReadResult = {
|
|
32
|
+
path: string;
|
|
33
|
+
content: string;
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
totalBytes?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const fsReadResultSchema = z.object({
|
|
39
|
+
path: z.string(),
|
|
40
|
+
content: z.string(),
|
|
41
|
+
truncated: z.boolean(),
|
|
42
|
+
totalBytes: z.number().int().nonnegative().optional(),
|
|
43
|
+
}).strict() as z.ZodType<FsReadResult>;
|
|
44
|
+
|
|
45
|
+
export function createFsReadToolHandler(options: FsReadToolOptions = {}): ToolHandler<FsReadInput, FsReadResult> {
|
|
46
|
+
const defaultMaxBytes = normalizeLimit(options.defaultMaxBytes, DEFAULT_TEXT_MAX_BYTES, HARD_MAX_TEXT_BYTES);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
descriptor: {
|
|
50
|
+
name: 'fs-read',
|
|
51
|
+
title: '读取沙箱文件',
|
|
52
|
+
description: '读取会话沙箱内的文本文件,并按字节上限返回内容。',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
required: ['path'],
|
|
57
|
+
properties: {
|
|
58
|
+
path: { type: 'string', minLength: 1, description: '沙箱内相对路径。' },
|
|
59
|
+
offset: { type: 'integer', minimum: 0, description: '读取内容的字节偏移。' },
|
|
60
|
+
limit: { type: 'integer', minimum: 1, description: '最多读取的字节数。' },
|
|
61
|
+
maxBytes: { type: 'integer', minimum: 1, description: '返回内容的最大字节数。' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
resultSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
required: ['path', 'content', 'truncated'],
|
|
68
|
+
properties: {
|
|
69
|
+
path: { type: 'string' },
|
|
70
|
+
content: { type: 'string' },
|
|
71
|
+
truncated: { type: 'boolean' },
|
|
72
|
+
totalBytes: { type: 'integer', minimum: 0 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
requiredResources: ['sandbox'],
|
|
76
|
+
annotations: {
|
|
77
|
+
readOnly: true,
|
|
78
|
+
idempotent: true,
|
|
79
|
+
openWorld: false,
|
|
80
|
+
destructive: false,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
inputSchema: fsReadInputSchema,
|
|
84
|
+
resultSchema: fsReadResultSchema,
|
|
85
|
+
execute: async (input, context): Promise<ToolExecution<FsReadResult>> => {
|
|
86
|
+
const sandbox = requireSandbox(context);
|
|
87
|
+
if (isToolExecution(sandbox)) {
|
|
88
|
+
return sandbox;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const path = normalizePathOrFailed<FsReadResult>(input.path);
|
|
92
|
+
if (isToolExecution(path)) {
|
|
93
|
+
return path;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const maxBytes = normalizeLimit(input.maxBytes, defaultMaxBytes, HARD_MAX_TEXT_BYTES);
|
|
98
|
+
const rawContent = await sandbox.readFile(path, {
|
|
99
|
+
...(input.offset !== undefined ? { offset: normalizeOffset(input.offset) } : {}),
|
|
100
|
+
...(input.limit !== undefined ? { limit: normalizeLimit(input.limit, 1, HARD_MAX_TEXT_BYTES) } : {}),
|
|
101
|
+
maxBytes: maxBytes + UTF8_READ_AHEAD_BYTES,
|
|
102
|
+
});
|
|
103
|
+
const truncated = truncateText(rawContent, maxBytes);
|
|
104
|
+
return {
|
|
105
|
+
status: 'completed',
|
|
106
|
+
modelResult: {
|
|
107
|
+
path,
|
|
108
|
+
content: truncated.text,
|
|
109
|
+
truncated: truncated.truncated,
|
|
110
|
+
...(truncated.totalBytes !== undefined ? { totalBytes: truncated.totalBytes } : {}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return failed('FS_READ_FAILED', error instanceof Error ? error.message : String(error));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeOffset(value: number | undefined): number {
|
|
121
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
return Math.max(0, Math.trunc(value));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isToolExecution<T>(value: unknown): value is ToolExecution<T> {
|
|
128
|
+
return value !== null && typeof value === 'object' && 'status' in value;
|
|
129
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
ToolExecution,
|
|
4
|
+
ToolHandler,
|
|
5
|
+
ToolHandlerContext,
|
|
6
|
+
} from '@ct-agents/protocol';
|
|
7
|
+
import {
|
|
8
|
+
approvalDenied,
|
|
9
|
+
isApprovalGranted,
|
|
10
|
+
pausedForApproval,
|
|
11
|
+
} from './approval.js';
|
|
12
|
+
import {
|
|
13
|
+
countUtf8Bytes,
|
|
14
|
+
failed,
|
|
15
|
+
normalizePathOrFailed,
|
|
16
|
+
requireSandbox,
|
|
17
|
+
} from './shared.js';
|
|
18
|
+
|
|
19
|
+
export type FsWriteToolOptions = {
|
|
20
|
+
requireApproval?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const fsWriteInputSchema = z.object({
|
|
24
|
+
path: z.string().trim().min(1, 'path 为必填字段'),
|
|
25
|
+
content: z.string(),
|
|
26
|
+
}).strict();
|
|
27
|
+
|
|
28
|
+
export type FsWriteInput = z.infer<typeof fsWriteInputSchema>;
|
|
29
|
+
|
|
30
|
+
export type FsWriteResult = {
|
|
31
|
+
path: string;
|
|
32
|
+
bytesWritten: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const fsWriteResultSchema = z.object({
|
|
36
|
+
path: z.string(),
|
|
37
|
+
bytesWritten: z.number().int().nonnegative(),
|
|
38
|
+
}).strict() as z.ZodType<FsWriteResult>;
|
|
39
|
+
|
|
40
|
+
export function createFsWriteToolHandler(options: FsWriteToolOptions = {}): ToolHandler<FsWriteInput, FsWriteResult> {
|
|
41
|
+
const requireApproval = options.requireApproval ?? true;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
descriptor: {
|
|
45
|
+
name: 'fs-write',
|
|
46
|
+
title: '写入沙箱文件',
|
|
47
|
+
description: '向会话沙箱内的文本文件写入内容。',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
required: ['path', 'content'],
|
|
52
|
+
properties: {
|
|
53
|
+
path: { type: 'string', minLength: 1, description: '沙箱内相对路径。' },
|
|
54
|
+
content: { type: 'string', description: '要写入的文本内容。' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
resultSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
required: ['path', 'bytesWritten'],
|
|
61
|
+
properties: {
|
|
62
|
+
path: { type: 'string' },
|
|
63
|
+
bytesWritten: { type: 'integer', minimum: 0 },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
requiredResources: ['sandbox'],
|
|
67
|
+
annotations: {
|
|
68
|
+
readOnly: false,
|
|
69
|
+
idempotent: false,
|
|
70
|
+
openWorld: false,
|
|
71
|
+
destructive: true,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
inputSchema: fsWriteInputSchema,
|
|
75
|
+
resultSchema: fsWriteResultSchema,
|
|
76
|
+
execute: async (input, context): Promise<ToolExecution<FsWriteResult>> => {
|
|
77
|
+
const path = normalizePathOrFailed<FsWriteResult>(input.path);
|
|
78
|
+
if (isToolExecution(path)) {
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (requireApproval) {
|
|
83
|
+
return pausedForApproval({
|
|
84
|
+
subject: 'fs-write',
|
|
85
|
+
summary: `写入文件:${path}`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return executeFsWrite(input, context, path);
|
|
90
|
+
},
|
|
91
|
+
resume: async (input, context) => {
|
|
92
|
+
if (!isApprovalGranted(input)) {
|
|
93
|
+
return approvalDenied();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const path = normalizePathOrFailed<FsWriteResult>(input.originalInput.path);
|
|
97
|
+
if (isToolExecution(path)) {
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return executeFsWrite(input.originalInput, context, path);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function executeFsWrite(
|
|
107
|
+
input: FsWriteInput,
|
|
108
|
+
context: ToolHandlerContext,
|
|
109
|
+
path: string,
|
|
110
|
+
): Promise<ToolExecution<FsWriteResult>> {
|
|
111
|
+
const sandbox = requireSandbox(context);
|
|
112
|
+
if (isToolExecution(sandbox)) {
|
|
113
|
+
return sandbox;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await sandbox.writeFile(path, input.content);
|
|
118
|
+
return {
|
|
119
|
+
status: 'completed',
|
|
120
|
+
modelResult: {
|
|
121
|
+
path,
|
|
122
|
+
bytesWritten: countUtf8Bytes(input.content),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return failed('FS_WRITE_FAILED', error instanceof Error ? error.message : String(error));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isToolExecution<T>(value: unknown): value is ToolExecution<T> {
|
|
131
|
+
return value !== null && typeof value === 'object' && 'status' in value;
|
|
132
|
+
}
|