@raolin2025/claude-code-node 1.0.0
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/README.md +294 -0
- package/package.json +24 -0
- package/src/core/cli.js +314 -0
- package/src/core/config.js +131 -0
- package/src/core/index.js +9 -0
- package/src/core/query-engine.js +344 -0
- package/src/core/session.js +120 -0
- package/src/core/streaming.js +119 -0
- package/src/core/token-budget.js +88 -0
- package/src/index.js +13 -0
- package/src/mcp/client.js +214 -0
- package/src/mcp/index.js +5 -0
- package/src/mcp/registry.js +176 -0
- package/src/permission/permission.js +37 -0
- package/src/security/bash-guard.js +279 -0
- package/src/security/enhanced-permission.js +310 -0
- package/src/security/index.js +7 -0
- package/src/security/path-guard.js +190 -0
- package/src/security/ssrf-guard.js +178 -0
- package/src/tools/ask-user.js +34 -0
- package/src/tools/bash.js +101 -0
- package/src/tools/file-edit.js +112 -0
- package/src/tools/file-read.js +105 -0
- package/src/tools/file-write.js +57 -0
- package/src/tools/glob.js +113 -0
- package/src/tools/grep.js +117 -0
- package/src/tools/index.js +110 -0
- package/src/tools/web-fetch.js +125 -0
- package/src/tools/web-search.js +75 -0
- package/src/types/index.js +126 -0
- package/src/utils/diff.js +181 -0
- package/src/utils/file-ops.js +124 -0
- package/src/utils/format.js +130 -0
- package/src/utils/index.js +7 -0
- package/src/utils/process.js +112 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径安全防护 — 防止路径遍历攻击和敏感文件访问
|
|
3
|
+
* 对应原版: src/utils/permissions/filesystem.ts + 多处路径检查
|
|
4
|
+
*/
|
|
5
|
+
import { resolve, normalize, isAbsolute, relative, sep } from 'path'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 敏感路径列表 — 禁止读写
|
|
9
|
+
*/
|
|
10
|
+
const FORBIDDEN_PATHS = [
|
|
11
|
+
'/etc/shadow',
|
|
12
|
+
'/etc/passwd',
|
|
13
|
+
'/etc/sudoers',
|
|
14
|
+
'/etc/ssh/sshd_config',
|
|
15
|
+
'/etc/gshadow',
|
|
16
|
+
'/etc/pam.d/',
|
|
17
|
+
'/boot/',
|
|
18
|
+
'/proc/sys/',
|
|
19
|
+
'/sys/kernel/',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 敏感路径前缀 — 需要额外确认
|
|
24
|
+
*/
|
|
25
|
+
const SENSITIVE_PREFIXES = [
|
|
26
|
+
'/etc/',
|
|
27
|
+
'/usr/local/',
|
|
28
|
+
'/var/log/',
|
|
29
|
+
'/root/',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 规范化路径 — 解析 .., ., 符号链接等
|
|
34
|
+
* @param {string} filePath — 输入路径
|
|
35
|
+
* @param {string} cwd — 当前工作目录
|
|
36
|
+
* @returns {string} 规范化后的绝对路径
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizePath(filePath, cwd = process.cwd()) {
|
|
39
|
+
// 如果是相对路径,基于 cwd 解析
|
|
40
|
+
const absPath = isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
|
|
41
|
+
// 规范化:消除 .. 和 .
|
|
42
|
+
return normalize(absPath)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 检查路径遍历攻击
|
|
47
|
+
* @param {string} filePath — 用户输入的路径
|
|
48
|
+
* @param {string} cwd — 工作目录
|
|
49
|
+
* @param {string[]} allowedDirs — 允许访问的目录列表
|
|
50
|
+
* @returns {{safe: boolean, resolvedPath: string, reason?: string}}
|
|
51
|
+
*/
|
|
52
|
+
export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs = []) {
|
|
53
|
+
const resolvedPath = sanitizePath(filePath, cwd)
|
|
54
|
+
|
|
55
|
+
// 1. 检查 .. 在原始路径中的使用
|
|
56
|
+
if (filePath.includes('..')) {
|
|
57
|
+
const normalizedRelative = relative(cwd, resolvedPath)
|
|
58
|
+
if (normalizedRelative.startsWith('..') || resolvedPath.startsWith('/etc/') || resolvedPath.startsWith('/root/')) {
|
|
59
|
+
return {
|
|
60
|
+
safe: false,
|
|
61
|
+
resolvedPath,
|
|
62
|
+
reason: `路径遍历:${filePath} 解析到 ${resolvedPath},超出工作目录范围`,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. 检查是否在允许的目录范围内
|
|
68
|
+
if (allowedDirs.length > 0) {
|
|
69
|
+
const isInAllowedDir = allowedDirs.some(dir => {
|
|
70
|
+
const normDir = normalize(isAbsolute(dir) ? dir : resolve(cwd, dir))
|
|
71
|
+
return resolvedPath.startsWith(normDir + sep) || resolvedPath === normDir
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (!isInAllowedDir) {
|
|
75
|
+
return {
|
|
76
|
+
safe: false,
|
|
77
|
+
resolvedPath,
|
|
78
|
+
reason: `路径 ${resolvedPath} 不在允许的目录范围内`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { safe: true, resolvedPath }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 检查是否为禁止访问的敏感路径
|
|
88
|
+
* @param {string} resolvedPath — 已规范化的绝对路径
|
|
89
|
+
* @returns {{allowed: boolean, reason?: string}}
|
|
90
|
+
*/
|
|
91
|
+
export function checkForbiddenPath(resolvedPath) {
|
|
92
|
+
// 1. 严格匹配禁止路径
|
|
93
|
+
for (const forbidden of FORBIDDEN_PATHS) {
|
|
94
|
+
if (resolvedPath === forbidden || resolvedPath.startsWith(forbidden + sep) || resolvedPath.startsWith(forbidden + '/')) {
|
|
95
|
+
return {
|
|
96
|
+
allowed: false,
|
|
97
|
+
reason: `禁止访问敏感路径: ${resolvedPath}(匹配规则: ${forbidden})`,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. SSH 目录特殊处理
|
|
103
|
+
if (resolvedPath.includes('/.ssh/') || resolvedPath.includes('\\.ssh\\')) {
|
|
104
|
+
// 允许读取 known_hosts 和 config,禁止读取私钥
|
|
105
|
+
const sshKeyPattern = /\/\.ssh\/id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i
|
|
106
|
+
const sshConfigPattern = /\/\.ssh\/(config|known_hosts|authorized_keys)$/i
|
|
107
|
+
|
|
108
|
+
if (sshKeyPattern.test(resolvedPath)) {
|
|
109
|
+
return {
|
|
110
|
+
allowed: false,
|
|
111
|
+
reason: `禁止访问 SSH 密钥文件: ${resolvedPath}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (sshConfigPattern.test(resolvedPath)) {
|
|
116
|
+
return {
|
|
117
|
+
allowed: true,
|
|
118
|
+
reason: `⚠️ 访问 SSH 配置文件: ${resolvedPath}`,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. 敏感前缀检查 — 允许但提示
|
|
124
|
+
for (const prefix of SENSITIVE_PREFIXES) {
|
|
125
|
+
if (resolvedPath.startsWith(prefix)) {
|
|
126
|
+
return {
|
|
127
|
+
allowed: true,
|
|
128
|
+
reason: `⚠️ 访问系统敏感目录: ${resolvedPath}`,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { allowed: true }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 综合路径安全检查
|
|
138
|
+
* @param {string} filePath — 用户输入路径
|
|
139
|
+
* @param {object} options — { cwd, allowedDirs, checkForbidden }
|
|
140
|
+
* @returns {{safe: boolean, resolvedPath: string, reasons: string[]}}
|
|
141
|
+
*/
|
|
142
|
+
export function checkPathSafety(filePath, options = {}) {
|
|
143
|
+
const { cwd = process.cwd(), allowedDirs = [], checkForbidden = true } = options
|
|
144
|
+
const reasons = []
|
|
145
|
+
|
|
146
|
+
// 路径遍历检查
|
|
147
|
+
const traversalResult = checkPathTraversal(filePath, cwd, allowedDirs)
|
|
148
|
+
const resolvedPath = traversalResult.resolvedPath
|
|
149
|
+
|
|
150
|
+
if (!traversalResult.safe) {
|
|
151
|
+
reasons.push(traversalResult.reason)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 禁止路径检查
|
|
155
|
+
if (checkForbidden) {
|
|
156
|
+
const forbiddenResult = checkForbiddenPath(resolvedPath)
|
|
157
|
+
if (!forbiddenResult.allowed) {
|
|
158
|
+
reasons.push(forbiddenResult.reason)
|
|
159
|
+
} else if (forbiddenResult.reason) {
|
|
160
|
+
reasons.push(forbiddenResult.reason)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
safe: !reasons.some(r => r.startsWith('禁止') || r.startsWith('路径遍历')),
|
|
166
|
+
resolvedPath,
|
|
167
|
+
reasons,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 验证写入路径 — 比读取更严格
|
|
173
|
+
* @param {string} filePath
|
|
174
|
+
* @param {object} options
|
|
175
|
+
* @returns {{safe: boolean, resolvedPath: string, reasons: string[]}}
|
|
176
|
+
*/
|
|
177
|
+
export function checkWritePathSafety(filePath, options = {}) {
|
|
178
|
+
const result = checkPathSafety(filePath, options)
|
|
179
|
+
|
|
180
|
+
// 写入额外检查:不能写到系统关键目录
|
|
181
|
+
const systemWriteDirs = ['/etc/', '/boot/', '/usr/bin/', '/usr/lib/', '/sbin/', '/bin/']
|
|
182
|
+
for (const dir of systemWriteDirs) {
|
|
183
|
+
if (result.resolvedPath.startsWith(dir)) {
|
|
184
|
+
result.safe = false
|
|
185
|
+
result.reasons.push(`禁止写入系统关键目录: ${dir}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return result
|
|
190
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF 防护 — 阻止对内网/云元数据端点的请求
|
|
3
|
+
* 对应原版: src/utils/hooks/ssrfGuard.ts
|
|
4
|
+
*/
|
|
5
|
+
import { lookup as dnsLookup } from 'dns'
|
|
6
|
+
import { isIP } from 'net'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 检查 IPv4 地址是否在应被阻止的范围内
|
|
10
|
+
*
|
|
11
|
+
* 阻止列表:
|
|
12
|
+
* - 0.0.0.0/8 — "this" 网络
|
|
13
|
+
* - 10.0.0.0/8 — 私有网络
|
|
14
|
+
* - 100.64.0.0/10 — CGNAT 共享地址(阿里云元数据 100.100.100.200)
|
|
15
|
+
* - 169.254.0.0/16 — 链路本地(AWS/GCP 云元数据)
|
|
16
|
+
* - 172.16.0.0/12 — 私有网络
|
|
17
|
+
* - 192.168.0.0/16 — 私有网络
|
|
18
|
+
* - 192.0.2.0/24 — TEST-NET-1 (RFC 5737)
|
|
19
|
+
* - 198.51.100.0/24 — TEST-NET-2
|
|
20
|
+
* - 203.0.113.0/24 — TEST-NET-3
|
|
21
|
+
*
|
|
22
|
+
* 允许:
|
|
23
|
+
* - 127.0.0.0/8 — 回环(本地开发 hook 服务器)
|
|
24
|
+
*/
|
|
25
|
+
function isBlockedV4(address) {
|
|
26
|
+
const parts = address.split('.').map(Number)
|
|
27
|
+
if (parts.length !== 4 || parts.some(n => Number.isNaN(n))) return false
|
|
28
|
+
|
|
29
|
+
const [a, b] = parts
|
|
30
|
+
|
|
31
|
+
// 回环 — 允许
|
|
32
|
+
if (a === 127) return false
|
|
33
|
+
|
|
34
|
+
// 0.0.0.0/8
|
|
35
|
+
if (a === 0) return true
|
|
36
|
+
|
|
37
|
+
// 10.0.0.0/8
|
|
38
|
+
if (a === 10) return true
|
|
39
|
+
|
|
40
|
+
// 100.64.0.0/10 — CGNAT (RFC 6598)
|
|
41
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
42
|
+
|
|
43
|
+
// 169.254.0.0/16 — 链路本地,云元数据
|
|
44
|
+
if (a === 169 && b === 254) return true
|
|
45
|
+
|
|
46
|
+
// 172.16.0.0/12
|
|
47
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
48
|
+
|
|
49
|
+
// 192.168.0.0/16
|
|
50
|
+
if (a === 192 && b === 168) return true
|
|
51
|
+
|
|
52
|
+
// 192.0.2.0/24 — TEST-NET-1
|
|
53
|
+
if (a === 192 && b === 0 && parts[2] === 2) return true
|
|
54
|
+
|
|
55
|
+
// 198.51.100.0/24 — TEST-NET-2
|
|
56
|
+
if (a === 198 && b === 51 && parts[2] === 100) return true
|
|
57
|
+
|
|
58
|
+
// 203.0.113.0/24 — TEST-NET-3
|
|
59
|
+
if (a === 203 && b === 0 && parts[2] === 113) return true
|
|
60
|
+
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 检查 IPv6 地址是否在应被阻止的范围内
|
|
66
|
+
*
|
|
67
|
+
* 阻止:
|
|
68
|
+
* - :: — 未指定地址
|
|
69
|
+
* - fc00::/7 — 唯一本地地址 (ULA)
|
|
70
|
+
* - fe80::/10 — 链路本地
|
|
71
|
+
* - ::ffff:<被阻止的v4> — IPv4 映射地址
|
|
72
|
+
*
|
|
73
|
+
* 允许:
|
|
74
|
+
* - ::1 — 回环
|
|
75
|
+
*/
|
|
76
|
+
function isBlockedV6(address) {
|
|
77
|
+
const normalized = address.toLowerCase()
|
|
78
|
+
|
|
79
|
+
// ::1 回环 — 允许
|
|
80
|
+
if (normalized === '::1') return false
|
|
81
|
+
|
|
82
|
+
// :: 未指定
|
|
83
|
+
if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true
|
|
84
|
+
|
|
85
|
+
// fc00::/7 — 唯一本地 (fc00:: - fdff::)
|
|
86
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true
|
|
87
|
+
|
|
88
|
+
// fe80::/10 — 链路本地
|
|
89
|
+
if (normalized.startsWith('fe8')) return true
|
|
90
|
+
|
|
91
|
+
// ::ffff:<IPv4> — IPv4 映射地址
|
|
92
|
+
const v4Mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
|
|
93
|
+
if (v4Mapped) {
|
|
94
|
+
return isBlockedV4(v4Mapped[1])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 检查 IP 地址是否在阻止列表中
|
|
102
|
+
* @param {string} address — IP 地址字符串
|
|
103
|
+
* @returns {boolean} true = 应阻止
|
|
104
|
+
*/
|
|
105
|
+
export function isBlockedAddress(address) {
|
|
106
|
+
const v = isIP(address)
|
|
107
|
+
if (v === 4) return isBlockedV4(address)
|
|
108
|
+
if (v === 6) return isBlockedV6(address)
|
|
109
|
+
// 不是合法 IP 字面量 — 交给 DNS 解析路径处理
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 解析主机名并检查解析结果是否安全
|
|
115
|
+
* @param {string} hostname — 主机名
|
|
116
|
+
* @returns {Promise<{allowed: boolean, addresses: string[], reason?: string}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function checkHostSafety(hostname) {
|
|
119
|
+
// 如果是 IP 字面量,直接检查
|
|
120
|
+
if (isIP(hostname)) {
|
|
121
|
+
const blocked = isBlockedAddress(hostname)
|
|
122
|
+
return {
|
|
123
|
+
allowed: !blocked,
|
|
124
|
+
addresses: [hostname],
|
|
125
|
+
reason: blocked ? `IP ${hostname} 在私有/保留地址范围内,可能为 SSRF 目标` : undefined,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// DNS 解析后检查
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
dnsLookup(hostname, (err, address) => {
|
|
132
|
+
if (err) {
|
|
133
|
+
// DNS 解析失败 — 允许(让 fetch 自己报错)
|
|
134
|
+
resolve({ allowed: true, addresses: [], reason: undefined })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const addresses = Array.isArray(address) ? address.map(a => a.address) : [address]
|
|
139
|
+
const blockedAddrs = addresses.filter(a => isBlockedAddress(a))
|
|
140
|
+
|
|
141
|
+
if (blockedAddrs.length > 0) {
|
|
142
|
+
resolve({
|
|
143
|
+
allowed: false,
|
|
144
|
+
addresses,
|
|
145
|
+
reason: `主机 ${hostname} 解析到私有地址 ${blockedAddrs.join(', ')},可能为 SSRF 目标`,
|
|
146
|
+
})
|
|
147
|
+
} else {
|
|
148
|
+
resolve({ allowed: true, addresses, reason: undefined })
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 检查 URL 是否安全(SSRF 防护)
|
|
156
|
+
* @param {string} url — 完整 URL
|
|
157
|
+
* @returns {Promise<{allowed: boolean, reason?: string}>}
|
|
158
|
+
*/
|
|
159
|
+
export async function checkUrlSafety(url) {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = new URL(url)
|
|
162
|
+
|
|
163
|
+
// 只允许 HTTP/HTTPS
|
|
164
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
165
|
+
return { allowed: false, reason: `不支持的协议: ${parsed.protocol}` }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 检查主机名
|
|
169
|
+
const hostResult = await checkHostSafety(parsed.hostname)
|
|
170
|
+
if (!hostResult.allowed) {
|
|
171
|
+
return { allowed: false, reason: hostResult.reason }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { allowed: true }
|
|
175
|
+
} catch {
|
|
176
|
+
return { allowed: false, reason: '无效的 URL' }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskUserQuestion 工具 — 向用户提问
|
|
3
|
+
* 对应原版: src/tools/AskUserQuestionTool/
|
|
4
|
+
*/
|
|
5
|
+
import { ToolDef } from '../types/index.js'
|
|
6
|
+
|
|
7
|
+
export const askUserTool = new ToolDef(
|
|
8
|
+
'AskUserQuestion',
|
|
9
|
+
`Ask the user a question and wait for their response.
|
|
10
|
+
Use this when you need clarification or user input to proceed.`,
|
|
11
|
+
{
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
question: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'The question to ask the user',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ['question'],
|
|
20
|
+
},
|
|
21
|
+
async (input, ctx) => {
|
|
22
|
+
// 在 CLI 模式下,通过 ctx 的 readline 接口提问
|
|
23
|
+
if (ctx?.readline) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
ctx.readline.question(`\n❓ ${input.question}\n> `, (answer) => {
|
|
26
|
+
resolve(answer.trim())
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
// 非 CLI 模式 — 返回提示信息
|
|
31
|
+
return `[AskUserQuestion: ${input.question} (no interactive terminal available)]`
|
|
32
|
+
},
|
|
33
|
+
'always-allow'
|
|
34
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash 工具 — 执行 shell 命令
|
|
3
|
+
* 对应原版: src/tools/BashTool/
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'child_process'
|
|
6
|
+
import { ToolDef } from '../types/index.js'
|
|
7
|
+
import { checkBashSafety } from '../security/bash-guard.js'
|
|
8
|
+
|
|
9
|
+
export const bashTool = new ToolDef(
|
|
10
|
+
'Bash',
|
|
11
|
+
`Execute a bash command. The command will run in a shell subprocess.
|
|
12
|
+
Usage:
|
|
13
|
+
- Provide the command as the value of the 'command' key
|
|
14
|
+
- Optionally specify a working directory with 'cwd'
|
|
15
|
+
- Optionally set a timeout in seconds with 'timeout' (default 120)
|
|
16
|
+
- The output will be returned as stdout+stderr combined
|
|
17
|
+
- If the command exits with non-zero, the result will be marked as an error`,
|
|
18
|
+
{
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
command: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'The bash command to execute',
|
|
24
|
+
},
|
|
25
|
+
cwd: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Working directory for the command (default: process.cwd())',
|
|
28
|
+
},
|
|
29
|
+
timeout: {
|
|
30
|
+
type: 'number',
|
|
31
|
+
description: 'Timeout in seconds (default: 120)',
|
|
32
|
+
},
|
|
33
|
+
env: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
description: 'Additional environment variables',
|
|
36
|
+
additionalProperties: { type: 'string' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ['command'],
|
|
40
|
+
},
|
|
41
|
+
async (input, ctx) => {
|
|
42
|
+
const command = input.command
|
|
43
|
+
const cwd = input.cwd || ctx.cwd || process.cwd()
|
|
44
|
+
const timeoutSec = input.timeout || 120
|
|
45
|
+
const extraEnv = input.env || {}
|
|
46
|
+
|
|
47
|
+
// 安全检查
|
|
48
|
+
const safetyResult = checkBashSafety(command)
|
|
49
|
+
if (!safetyResult.allowed) {
|
|
50
|
+
return `[🚫 命令被安全策略阻止]\n${safetyResult.reasons.join('\n')}\n\n如果确认需要执行,请使用 /allow Bash 命令`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const shell = process.env.SHELL || '/bin/bash'
|
|
55
|
+
const proc = spawn(shell, ['-c', command], {
|
|
56
|
+
cwd,
|
|
57
|
+
env: { ...process.env, ...extraEnv },
|
|
58
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
let stdout = ''
|
|
62
|
+
let stderr = ''
|
|
63
|
+
|
|
64
|
+
proc.stdout.on('data', (data) => {
|
|
65
|
+
stdout += data.toString()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
proc.stderr.on('data', (data) => {
|
|
69
|
+
stderr += data.toString()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
proc.kill('SIGTERM')
|
|
74
|
+
// 给进程 5 秒优雅退出
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
try { proc.kill('SIGKILL') } catch {}
|
|
77
|
+
}, 5000)
|
|
78
|
+
resolve(`[Timeout after ${timeoutSec}s]\n${stdout}${stderr ? '\n--- stderr ---\n' + stderr : ''}`)
|
|
79
|
+
}, timeoutSec * 1000)
|
|
80
|
+
|
|
81
|
+
proc.on('close', (code) => {
|
|
82
|
+
clearTimeout(timer)
|
|
83
|
+
const output = stdout + (stderr ? '\n--- stderr ---\n' + stderr : '')
|
|
84
|
+
if (code === 0) {
|
|
85
|
+
resolve(output || '(no output)')
|
|
86
|
+
} else {
|
|
87
|
+
resolve(`[Exit code: ${code}]\n${output}`)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
proc.on('error', (err) => {
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
resolve(`[Error: ${err.message}]`)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// 关闭 stdin
|
|
97
|
+
proc.stdin.end()
|
|
98
|
+
})
|
|
99
|
+
},
|
|
100
|
+
'ask'
|
|
101
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileEdit 工具 — 精确文本替换编辑文件
|
|
3
|
+
* 对应原版: src/tools/FileEditTool/
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile } from 'fs/promises'
|
|
6
|
+
import { resolve, isAbsolute } from 'path'
|
|
7
|
+
import { ToolDef } from '../types/index.js'
|
|
8
|
+
import { checkWritePathSafety } from '../security/path-guard.js'
|
|
9
|
+
|
|
10
|
+
export const fileEditTool = new ToolDef(
|
|
11
|
+
'Edit',
|
|
12
|
+
`Perform exact string replacements in a file.
|
|
13
|
+
Usage:
|
|
14
|
+
- file_path must be an absolute path
|
|
15
|
+
- old_string must match EXACTLY (including whitespace/indentation)
|
|
16
|
+
- new_string is the replacement text
|
|
17
|
+
- Use replace_all=true to replace all occurrences (default: false)
|
|
18
|
+
- The tool will fail if old_string is not found or appears multiple times without replace_all`,
|
|
19
|
+
{
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
file_path: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'The absolute path to the file to modify',
|
|
25
|
+
},
|
|
26
|
+
old_string: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'The text to replace',
|
|
29
|
+
},
|
|
30
|
+
new_string: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'The text to replace it with (must be different from old_string)',
|
|
33
|
+
},
|
|
34
|
+
replace_all: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
description: 'Replace all occurrences of old_string (default false)',
|
|
37
|
+
default: false,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ['file_path', 'old_string', 'new_string'],
|
|
41
|
+
},
|
|
42
|
+
async (input, ctx) => {
|
|
43
|
+
let filePath = input.file_path
|
|
44
|
+
if (!isAbsolute(filePath)) {
|
|
45
|
+
filePath = resolve(ctx.cwd || process.cwd(), filePath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { old_string, new_string, replace_all = false } = input
|
|
49
|
+
|
|
50
|
+
// 写入路径安全检查
|
|
51
|
+
const pathResult = checkWritePathSafety(filePath, { cwd: ctx.cwd || process.cwd() })
|
|
52
|
+
if (!pathResult.safe) {
|
|
53
|
+
return `[🚫 路径被安全策略阻止]\n${pathResult.reasons.join('\n')}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (old_string === new_string) {
|
|
57
|
+
return '[Error: old_string and new_string are identical. No change needed.]'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const content = await readFile(filePath, 'utf-8')
|
|
62
|
+
|
|
63
|
+
// 检查 old_string 是否存在
|
|
64
|
+
if (!content.includes(old_string)) {
|
|
65
|
+
// 尝试提供有用的错误信息
|
|
66
|
+
const lines = content.split('\n')
|
|
67
|
+
const snippet = old_string.split('\n')[0].slice(0, 80)
|
|
68
|
+
const suggestions = []
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
if (lines[i].includes(snippet) || lines[i].trim() === snippet.trim()) {
|
|
71
|
+
suggestions.push(` Line ${i + 1}: ${lines[i].slice(0, 80)}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let msg = `[Error: old_string not found in ${filePath}]`
|
|
75
|
+
if (suggestions.length > 0) {
|
|
76
|
+
msg += `\nPossible matches:\n${suggestions.join('\n')}`
|
|
77
|
+
}
|
|
78
|
+
return msg
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 检查多次出现
|
|
82
|
+
const count = content.split(old_string).length - 1
|
|
83
|
+
if (count > 1 && !replace_all) {
|
|
84
|
+
return `[Error: old_string appears ${count} times in the file. Use replace_all=true to replace all occurrences, or provide more context to make the match unique.]`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 执行替换
|
|
88
|
+
let newContent
|
|
89
|
+
if (replace_all) {
|
|
90
|
+
newContent = content.split(old_string).join(new_string)
|
|
91
|
+
} else {
|
|
92
|
+
const idx = content.indexOf(old_string)
|
|
93
|
+
newContent = content.slice(0, idx) + new_string + content.slice(idx + old_string.length)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await writeFile(filePath, newContent, 'utf-8')
|
|
97
|
+
|
|
98
|
+
// 计算变更行数
|
|
99
|
+
const oldLines = old_string.split('\n').length
|
|
100
|
+
const newLines = new_string.split('\n').length
|
|
101
|
+
const diff = newLines - oldLines
|
|
102
|
+
|
|
103
|
+
return `Successfully edited ${filePath} (${count > 1 ? count + ' occurrences' : '1 occurrence'} replaced, ${diff >= 0 ? '+' : ''}${diff} lines)`
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.code === 'ENOENT') {
|
|
106
|
+
return `[Error: File not found: ${filePath}]`
|
|
107
|
+
}
|
|
108
|
+
return `[Error editing file: ${err.message}]`
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
'ask'
|
|
112
|
+
)
|