@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,39 @@
|
|
|
1
|
+
import type { ToolHandler } from '@ct-agents/protocol';
|
|
2
|
+
import { createBashToolHandler, type BashToolOptions } from './bash.js';
|
|
3
|
+
import { createDbExecuteToolHandler, type DbExecuteToolOptions } from './db-execute.js';
|
|
4
|
+
import { createDbQueryToolHandler, type DbQueryToolOptions } from './db-query.js';
|
|
5
|
+
import { createFsReadToolHandler, type FsReadToolOptions } from './fs-read.js';
|
|
6
|
+
import { createFsWriteToolHandler, type FsWriteToolOptions } from './fs-write.js';
|
|
7
|
+
|
|
8
|
+
export type CreateSandboxToolsOptions = {
|
|
9
|
+
dbQuery?: DbQueryToolOptions;
|
|
10
|
+
fsRead?: FsReadToolOptions;
|
|
11
|
+
fsWrite?: FsWriteToolOptions;
|
|
12
|
+
bash?: BashToolOptions;
|
|
13
|
+
allowDbWrite?: boolean;
|
|
14
|
+
dbExecute?: Omit<DbExecuteToolOptions, 'allowWrite'>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createSandboxTools(options: CreateSandboxToolsOptions = {}): ToolHandler[] {
|
|
18
|
+
return [
|
|
19
|
+
createDbQueryToolHandler(options.dbQuery),
|
|
20
|
+
createFsReadToolHandler(options.fsRead),
|
|
21
|
+
createFsWriteToolHandler(options.fsWrite),
|
|
22
|
+
createBashToolHandler(options.bash),
|
|
23
|
+
createDbExecuteToolHandler({
|
|
24
|
+
...options.dbExecute,
|
|
25
|
+
allowWrite: options.allowDbWrite ?? false,
|
|
26
|
+
}),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export * from './approval.js';
|
|
31
|
+
export * from './bash.js';
|
|
32
|
+
export * from './db-execute.js';
|
|
33
|
+
export * from './db-query.js';
|
|
34
|
+
export * from './fs-read.js';
|
|
35
|
+
export * from './fs-write.js';
|
|
36
|
+
export * from './path-jail.js';
|
|
37
|
+
export * from './shared.js';
|
|
38
|
+
export * from './sql-guard.js';
|
|
39
|
+
export * from './truncate.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isAbsolute, normalize } from 'node:path';
|
|
2
|
+
import { isAbsolute as isPosixAbsolute, normalize as normalizePosix } from 'node:path/posix';
|
|
3
|
+
import { isAbsolute as isWinAbsolute, normalize as normalizeWin } from 'node:path/win32';
|
|
4
|
+
|
|
5
|
+
export const SANDBOX_PATH_OUT_OF_JAIL = 'SANDBOX_PATH_OUT_OF_JAIL';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 工具层只处理词法级路径 jail:拒绝绝对路径和 `..` 逃逸,并把路径规范成 sandbox 内相对路径。
|
|
9
|
+
* symlink/真实路径逃逸必须由具体 SandboxHandle 在执行读写时继续兜底。
|
|
10
|
+
*/
|
|
11
|
+
export function resolveSandboxPath(path: string): string {
|
|
12
|
+
const trimmed = path.trim();
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
throw new Error('路径不能为空');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (isAbsolute(trimmed) || isPosixAbsolute(trimmed) || isWinAbsolute(trimmed)) {
|
|
18
|
+
throw new Error(`路径越界:${path}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const slashPath = trimmed.replace(/\\/g, '/');
|
|
22
|
+
const normalized = normalizePosix(slashPath);
|
|
23
|
+
const nativeNormalized = normalize(trimmed);
|
|
24
|
+
const winNormalized = normalizeWin(trimmed);
|
|
25
|
+
if (isEscaping(normalized) || isEscaping(nativeNormalized) || isEscaping(winNormalized)) {
|
|
26
|
+
throw new Error(`路径越界:${path}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return normalized === '.' ? '' : normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isEscaping(path: string): boolean {
|
|
33
|
+
return path === '..'
|
|
34
|
+
|| path.startsWith('../')
|
|
35
|
+
|| path.startsWith('..\\');
|
|
36
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SandboxHandle,
|
|
3
|
+
ToolExecution,
|
|
4
|
+
} from '@ct-agents/protocol';
|
|
5
|
+
import { SANDBOX_PATH_OUT_OF_JAIL, resolveSandboxPath } from './path-jail.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TEXT_MAX_BYTES = 128_000;
|
|
8
|
+
export const DEFAULT_MAX_ROWS = 100;
|
|
9
|
+
export const HARD_MAX_TEXT_BYTES = 1_048_576;
|
|
10
|
+
export const HARD_MAX_ROWS = 1_000;
|
|
11
|
+
export const HARD_MAX_TIMEOUT_MS = 300_000;
|
|
12
|
+
|
|
13
|
+
export function normalizeLimit(value: number | undefined, fallback: number, hardMax?: number): number {
|
|
14
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
15
|
+
return clampPositiveInteger(fallback, hardMax);
|
|
16
|
+
}
|
|
17
|
+
return clampPositiveInteger(value, hardMax);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeBoolean(value: boolean | undefined, fallback: boolean): boolean {
|
|
21
|
+
return value ?? fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function countUtf8Bytes(text: string): number {
|
|
25
|
+
return new TextEncoder().encode(text).byteLength;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireSandbox(context: { resources: { sandbox?: SandboxHandle } }): SandboxHandle | ToolExecution<never> {
|
|
29
|
+
const sandbox = context.resources.sandbox;
|
|
30
|
+
if (!sandbox) {
|
|
31
|
+
return failed('SANDBOX_RESOURCE_MISSING', '当前工具上下文缺少 sandbox resource');
|
|
32
|
+
}
|
|
33
|
+
return sandbox;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizePathOrFailed<T>(path: string): string | ToolExecution<T> {
|
|
37
|
+
try {
|
|
38
|
+
return resolveSandboxPath(path);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return failed(
|
|
41
|
+
SANDBOX_PATH_OUT_OF_JAIL,
|
|
42
|
+
error instanceof Error ? error.message : String(error),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function failed<T>(code: string, message: string): ToolExecution<T> {
|
|
48
|
+
return {
|
|
49
|
+
status: 'failed',
|
|
50
|
+
error: {
|
|
51
|
+
code,
|
|
52
|
+
message,
|
|
53
|
+
retryable: false,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clampPositiveInteger(value: number, hardMax: number | undefined): number {
|
|
59
|
+
const normalized = Math.max(1, Math.trunc(value));
|
|
60
|
+
if (hardMax === undefined) {
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
return Math.min(normalized, Math.max(1, Math.trunc(hardMax)));
|
|
64
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export type SqlStructureScan = {
|
|
2
|
+
structureSql: string;
|
|
3
|
+
hasStatementSeparator: boolean;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function scanSqlStructure(sql: string): SqlStructureScan {
|
|
7
|
+
let output = '';
|
|
8
|
+
let blockDepth = 0;
|
|
9
|
+
let inLineComment = false;
|
|
10
|
+
let index = 0;
|
|
11
|
+
let hasStatementSeparator = false;
|
|
12
|
+
|
|
13
|
+
while (index < sql.length) {
|
|
14
|
+
const current = sql[index];
|
|
15
|
+
const next = sql[index + 1];
|
|
16
|
+
|
|
17
|
+
if (inLineComment) {
|
|
18
|
+
if (current === '\n' || current === '\r') {
|
|
19
|
+
inLineComment = false;
|
|
20
|
+
output += current;
|
|
21
|
+
}
|
|
22
|
+
index += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (blockDepth > 0) {
|
|
27
|
+
if (current === '/' && next === '*') {
|
|
28
|
+
blockDepth += 1;
|
|
29
|
+
index += 2;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (current === '*' && next === '/') {
|
|
33
|
+
blockDepth -= 1;
|
|
34
|
+
index += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (current === '\'') {
|
|
42
|
+
output += ' ';
|
|
43
|
+
index = consumeSingleQuotedString(sql, index);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dollarQuoteTag = readDollarQuoteTag(sql, index);
|
|
48
|
+
if (dollarQuoteTag) {
|
|
49
|
+
output += ' ';
|
|
50
|
+
index = consumeDollarQuotedString(sql, index, dollarQuoteTag);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (current === '-' && next === '-') {
|
|
55
|
+
inLineComment = true;
|
|
56
|
+
index += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (current === '/' && next === '*') {
|
|
61
|
+
blockDepth = 1;
|
|
62
|
+
index += 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (current === ';') {
|
|
67
|
+
hasStatementSeparator = true;
|
|
68
|
+
index += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
output += current;
|
|
73
|
+
index += 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
structureSql: output,
|
|
78
|
+
hasStatementSeparator,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function containsDangerousPostgresCapability(sql: string): boolean {
|
|
83
|
+
return /\b(pg_read_file|pg_read_binary_file|pg_ls_dir|pg_ls_logdir|pg_ls_waldir|pg_stat_file|pg_stat_directory|lo_import|lo_export|lo_get|lo_put|loread|lowrite|pg_sleep|pg_sleep_for|pg_sleep_until|pg_notify|pg_advisory_lock|pg_advisory_lock_shared|pg_advisory_xact_lock|pg_advisory_xact_lock_shared|pg_try_advisory_lock|pg_try_advisory_lock_shared|pg_try_advisory_xact_lock|pg_try_advisory_xact_lock_shared|pg_advisory_unlock|pg_advisory_unlock_shared|pg_advisory_unlock_all|pg_terminate_backend|pg_cancel_backend|pg_signal_backend|pg_reload_conf|pg_rotate_logfile|pg_logdir_ls|pg_largeobject|pg_get_functiondef|pg_get_viewdef|pg_get_ruledef|pg_get_triggerdef|set_config|dblink|dblink_connect|dblink_exec|dblink_send_query)\b/i.test(sql)
|
|
84
|
+
|| /\b(from|join)\s+(?:pg_catalog\.)?(pg_stat_activity|pg_shadow|pg_roles|pg_database|pg_authid|pg_hba_file_rules|pg_db_role_setting)\b/i.test(sql)
|
|
85
|
+
|| /\b(from|join)\s+information_schema\.(role_table_grants|applicable_roles|enabled_roles|administrable_role_authorizations)\b/i.test(sql);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function containsWriteOrDdlKeyword(sql: string): boolean {
|
|
89
|
+
return /\b(insert|update|delete|merge|truncate|drop|alter|create|grant|revoke|vacuum|copy|call|set|reset|into|analyze|cluster|reindex|refresh)\b/i.test(sql)
|
|
90
|
+
|| /\bcomment\s+on\b/i.test(sql);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function containsDdlDclOrMaintenanceKeyword(sql: string): boolean {
|
|
94
|
+
return /\b(truncate|drop|alter|create|grant|revoke|vacuum|copy|call|reset|analyze|cluster|reindex|refresh)\b/i.test(sql)
|
|
95
|
+
|| /\bcomment\s+on\b/i.test(sql);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function consumeSingleQuotedString(sql: string, startIndex: number): number {
|
|
99
|
+
let index = startIndex + 1;
|
|
100
|
+
while (index < sql.length) {
|
|
101
|
+
const current = sql[index];
|
|
102
|
+
const next = sql[index + 1];
|
|
103
|
+
if (current === '\'' && next === '\'') {
|
|
104
|
+
index += 2;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (current === '\'') {
|
|
108
|
+
return index + 1;
|
|
109
|
+
}
|
|
110
|
+
index += 1;
|
|
111
|
+
}
|
|
112
|
+
return index;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readDollarQuoteTag(sql: string, startIndex: number): string | null {
|
|
116
|
+
if (sql[startIndex] !== '$') {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rest = sql.slice(startIndex);
|
|
121
|
+
const match = rest.match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
|
122
|
+
return match?.[0] ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function consumeDollarQuotedString(sql: string, startIndex: number, tag: string): number {
|
|
126
|
+
const endIndex = sql.indexOf(tag, startIndex + tag.length);
|
|
127
|
+
return endIndex === -1 ? sql.length : endIndex + tag.length;
|
|
128
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type TruncatedRows<T> = {
|
|
2
|
+
rows: T[];
|
|
3
|
+
truncated: boolean;
|
|
4
|
+
totalRows?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type TruncatedText = {
|
|
8
|
+
text: string;
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
totalBytes?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function truncateRows<T>(rows: readonly T[], maxRows: number): TruncatedRows<T> {
|
|
14
|
+
const normalizedMaxRows = normalizeLimit(maxRows, 1);
|
|
15
|
+
if (rows.length <= normalizedMaxRows) {
|
|
16
|
+
return {
|
|
17
|
+
rows: [...rows],
|
|
18
|
+
truncated: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
rows: rows.slice(0, normalizedMaxRows),
|
|
24
|
+
truncated: true,
|
|
25
|
+
totalRows: rows.length,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function truncateText(text: string, maxBytes: number): TruncatedText {
|
|
30
|
+
const normalizedMaxBytes = normalizeLimit(maxBytes, 1);
|
|
31
|
+
const bytes = new TextEncoder().encode(text);
|
|
32
|
+
if (bytes.byteLength <= normalizedMaxBytes) {
|
|
33
|
+
return { text, truncated: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const truncatedBytes = sliceCompleteUtf8(bytes, normalizedMaxBytes);
|
|
37
|
+
return {
|
|
38
|
+
text: new TextDecoder().decode(truncatedBytes),
|
|
39
|
+
truncated: true,
|
|
40
|
+
totalBytes: bytes.byteLength,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sliceCompleteUtf8(bytes: Uint8Array, maxBytes: number): Uint8Array {
|
|
45
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
46
|
+
for (let end = Math.min(maxBytes, bytes.byteLength); end >= 0; end -= 1) {
|
|
47
|
+
const candidate = bytes.slice(0, end);
|
|
48
|
+
try {
|
|
49
|
+
decoder.decode(candidate);
|
|
50
|
+
return candidate;
|
|
51
|
+
} catch {
|
|
52
|
+
// 回退到上一个完整 UTF-8 字符边界。
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return new Uint8Array();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeLimit(value: number, fallback: number): number {
|
|
59
|
+
if (!Number.isFinite(value)) {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
return Math.max(1, Math.trunc(value));
|
|
63
|
+
}
|