@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.
@@ -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
+ )