@raolin2025/claude-code-node 1.2.0 → 2.1.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.
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * 权限系统增强版 — 规则持久化 + 审计日志
3
- * 对应原版: src/hooks/toolPermission/ + src/utils/permissions/
3
+ * 对应原版:src/hooks/toolPermission/ + src/utils/permissions/
4
4
  */
5
- import { readFile, writeFile, mkdir } from 'fs/promises'
6
- import { resolve, join } from 'path'
5
+ import { readFile, writeFile, mkdir, stat, rename } from 'fs/promises'
6
+ import { join } from 'path'
7
7
  import { checkBashSafety } from './bash-guard.js'
8
8
  import { checkPathSafety, checkWritePathSafety } from './path-guard.js'
9
9
  import { checkUrlSafety } from './ssrf-guard.js'
10
10
 
11
11
  const PERMISSIONS_FILE = '.claude-code/permissions.json'
12
12
  const AUDIT_LOG_FILE = '.claude-code/audit.log'
13
+ const AUDIT_LOG_MAX_SIZE = 10 * 1024 * 1024 // 10MB 审计日志上限
14
+ const AUDIT_LOG_MAX_BACKUPS = 3 // 最多保留 3 个轮转备份
13
15
 
14
16
  /**
15
17
  * 权限决策类型
@@ -25,10 +27,10 @@ export const PermissionDecision = {
25
27
  */
26
28
  export class PermissionRule {
27
29
  constructor({ tool, pattern, decision, reason, expiresAt = null }) {
28
- this.tool = tool // 工具名或 '*'(所有工具)
29
- this.pattern = pattern // 匹配模式(glob 或 regex 字符串)
30
- this.decision = decision // allow / deny / ask
31
- this.reason = reason // 规则原因
30
+ this.tool = tool // 工具名或 '*'(所有工具)
31
+ this.pattern = pattern // 匹配模式(glob 或 regex 字符串)
32
+ this.decision = decision // allow / deny / ask
33
+ this.reason = reason // 规则原因
32
34
  this.createdAt = Date.now()
33
35
  this.expiresAt = expiresAt // 过期时间(会话级规则)
34
36
  }
@@ -41,21 +43,17 @@ export class PermissionRule {
41
43
  /** 检查输入是否匹配此规则 */
42
44
  matches(input) {
43
45
  if (this.isExpired) return false
44
-
45
46
  // 简单 glob 匹配
46
47
  const pattern = this.pattern
47
48
  if (pattern === '*') return true
48
-
49
49
  // 路径模式
50
50
  if (typeof input === 'string' && input.includes('/')) {
51
51
  return this._globMatch(pattern, input)
52
52
  }
53
-
54
53
  // 命令模式
55
54
  if (typeof input === 'string') {
56
55
  return input.startsWith(pattern) || this._globMatch(pattern, input)
57
56
  }
58
-
59
57
  return false
60
58
  }
61
59
 
@@ -74,8 +72,8 @@ export class PermissionRule {
74
72
  export class EnhancedPermissionChecker {
75
73
  constructor(mode = 'ask', options = {}) {
76
74
  this.mode = mode
77
- this.rules = [] // PermissionRule 列表
78
- this.auditLog = [] // 审计日志
75
+ this.rules = [] // PermissionRule 列表
76
+ this.auditLog = [] // 审计日志
79
77
  this.cwd = options.cwd || process.cwd()
80
78
  this.projectDir = options.projectDir || process.cwd()
81
79
  this._maxAuditEntries = 1000
@@ -117,7 +115,7 @@ export class EnhancedPermissionChecker {
117
115
  * 综合权限检查
118
116
  * @param {string} toolName — 工具名
119
117
  * @param {object} input — 工具输入参数
120
- * @returns {Promise<{allowed: boolean, reason?: string, securityCheck?: object}>}
118
+ * @returns {Promise<{allowed: boolean, reason?: string, requiresConfirmation?: boolean, securityCheck?: object}>}
121
119
  */
122
120
  async check(toolName, input = {}) {
123
121
  // 1. 模式级检查
@@ -125,6 +123,7 @@ export class EnhancedPermissionChecker {
125
123
  this._log(toolName, input, false, '全局拒绝模式')
126
124
  return { allowed: false, reason: '全局拒绝模式' }
127
125
  }
126
+
128
127
  if (this.mode === 'always-allow') {
129
128
  const securityResult = await this._securityCheck(toolName, input)
130
129
  if (!securityResult.safe) {
@@ -141,7 +140,7 @@ export class EnhancedPermissionChecker {
141
140
  if (rule.tool === toolName || rule.tool === '*') {
142
141
  if (rule.matches(this._extractPattern(toolName, input))) {
143
142
  if (rule.decision === PermissionDecision.DENY) {
144
- this._log(toolName, input, false, `规则拒绝: ${rule.reason}`)
143
+ this._log(toolName, input, false, `规则拒绝:${rule.reason}`)
145
144
  return { allowed: false, reason: rule.reason }
146
145
  }
147
146
  if (rule.decision === PermissionDecision.ALLOW) {
@@ -151,7 +150,7 @@ export class EnhancedPermissionChecker {
151
150
  this._log(toolName, input, false, securityResult.reason)
152
151
  return { allowed: false, reason: securityResult.reason, securityCheck: securityResult }
153
152
  }
154
- this._log(toolName, input, true, `规则允许: ${rule.reason}`)
153
+ this._log(toolName, input, true, `规则允许:${rule.reason}`)
155
154
  return { allowed: true }
156
155
  }
157
156
  }
@@ -166,9 +165,12 @@ export class EnhancedPermissionChecker {
166
165
  }
167
166
 
168
167
  // 4. ask 模式 — 需要用户确认
168
+ // 返回 requiresConfirmation=true,让调用方处理确认逻辑
169
169
  this._log(toolName, input, true, 'ask 模式 — 等待用户确认')
170
170
  return {
171
- allowed: true, // 简化版直接允许(完整版应弹出确认对话框)
171
+ allowed: false, // ask 模式下先拒绝,等待用户确认
172
+ requiresConfirmation: true, // 标记需要用户确认
173
+ reason: 'ask 模式需要用户确认',
172
174
  securityCheck: securityResult,
173
175
  }
174
176
  }
@@ -188,7 +190,6 @@ export class EnhancedPermissionChecker {
188
190
  detail: result,
189
191
  }
190
192
  }
191
-
192
193
  case 'Read': {
193
194
  const filePath = input.file_path || ''
194
195
  if (!filePath) return { safe: true }
@@ -199,7 +200,6 @@ export class EnhancedPermissionChecker {
199
200
  detail: result,
200
201
  }
201
202
  }
202
-
203
203
  case 'Edit':
204
204
  case 'Write': {
205
205
  const filePath = input.file_path || ''
@@ -212,7 +212,6 @@ export class EnhancedPermissionChecker {
212
212
  detail: result,
213
213
  }
214
214
  }
215
-
216
215
  case 'WebFetch': {
217
216
  const url = input.url || ''
218
217
  if (!url) return { safe: true }
@@ -222,7 +221,6 @@ export class EnhancedPermissionChecker {
222
221
  reason: result.reason,
223
222
  }
224
223
  }
225
-
226
224
  default:
227
225
  return { safe: true }
228
226
  }
@@ -231,15 +229,20 @@ export class EnhancedPermissionChecker {
231
229
  /** 提取规则匹配用的模式字符串 */
232
230
  _extractPattern(toolName, input) {
233
231
  switch (toolName) {
234
- case 'Bash': return input.command || ''
232
+ case 'Bash':
233
+ return input.command || ''
235
234
  case 'Read':
236
235
  case 'Edit':
237
- case 'Write': return input.file_path || ''
236
+ case 'Write':
237
+ return input.file_path || ''
238
238
  case 'Glob':
239
- case 'Grep': return input.path || input.pattern || ''
239
+ case 'Grep':
240
+ return input.path || input.pattern || ''
240
241
  case 'WebFetch':
241
- case 'WebSearch': return input.url || input.query || ''
242
- default: return JSON.stringify(input)
242
+ case 'WebSearch':
243
+ return input.url || input.query || ''
244
+ default:
245
+ return JSON.stringify(input)
243
246
  }
244
247
  }
245
248
 
@@ -252,7 +255,6 @@ export class EnhancedPermissionChecker {
252
255
  allowed,
253
256
  reason,
254
257
  })
255
-
256
258
  // 限制审计日志大小
257
259
  if (this.auditLog.length > this._maxAuditEntries) {
258
260
  this.auditLog = this.auditLog.slice(-this._maxAuditEntries)
@@ -271,7 +273,11 @@ export class EnhancedPermissionChecker {
271
273
  decision: r.decision,
272
274
  reason: r.reason,
273
275
  }))
274
- await writeFile(join(dir, 'permissions.json'), JSON.stringify(data, null, 2), 'utf-8')
276
+ // v1.1 修复:文件权限 0600(仅所有者可读写),防止其他用户读取权限规则
277
+ await writeFile(join(dir, 'permissions.json'), JSON.stringify(data, null, 2), {
278
+ encoding: 'utf-8',
279
+ mode: 0o600,
280
+ })
275
281
  }
276
282
 
277
283
  /** 加载权限规则 */
@@ -287,19 +293,51 @@ export class EnhancedPermissionChecker {
287
293
  }
288
294
  }
289
295
 
290
- /** 保存审计日志 */
296
+ /** 保存审计日志(v1.1: 增加轮转,防止磁盘耗尽) */
291
297
  async saveAuditLog() {
292
298
  const dir = join(this.projectDir, '.claude-code')
293
299
  await mkdir(dir, { recursive: true })
294
- const lines = this.auditLog.map(e =>
295
- `${e.timestamp} | ${e.tool} | ${e.allowed ? 'ALLOW' : 'DENY'} | ${e.reason} | ${e.inputSnippet}`
296
- ).join('\n')
297
- await writeFile(join(dir, 'audit.log'), lines, 'utf-8')
300
+ const logPath = join(dir, 'audit.log')
301
+
302
+ // 检查现有日志大小,超过上限则轮转
303
+ try {
304
+ const logStat = await stat(logPath)
305
+ if (logStat.size >= AUDIT_LOG_MAX_SIZE) {
306
+ // 轮转:audit.log → audit.log.1 → audit.log.2 → audit.log.3(最老的删除)
307
+ for (let i = AUDIT_LOG_MAX_BACKUPS; i >= 1; i--) {
308
+ const src = i === 1 ? logPath : join(dir, `audit.log.${i - 1}`)
309
+ const dst = join(dir, `audit.log.${i}`)
310
+ try {
311
+ if (i === AUDIT_LOG_MAX_BACKUPS) {
312
+ // 最老的备份直接删除
313
+ const { unlink } = await import('fs/promises')
314
+ await unlink(dst).catch(() => {})
315
+ }
316
+ await rename(src, dst).catch(() => {})
317
+ } catch {
318
+ /* 忽略轮转错误 */
319
+ }
320
+ }
321
+ }
322
+ } catch {
323
+ /* 日志文件不存在,首次写入 */
324
+ }
325
+
326
+ const lines = this.auditLog
327
+ .map(e => `${e.timestamp} | ${e.tool} | ${e.allowed ? 'ALLOW' : 'DENY'} | ${e.reason} | ${e.inputSnippet}`)
328
+ .join('\n')
329
+ // v1.1 修复:文件权限 0600
330
+ await writeFile(logPath, lines, { encoding: 'utf-8', mode: 0o600 })
298
331
  }
299
332
 
300
333
  /** 获取审计摘要 */
301
334
  getAuditSummary() {
302
- const summary = { total: this.auditLog.length, allowed: 0, denied: 0, byTool: {} }
335
+ const summary = {
336
+ total: this.auditLog.length,
337
+ allowed: 0,
338
+ denied: 0,
339
+ byTool: {},
340
+ }
303
341
  for (const entry of this.auditLog) {
304
342
  if (entry.allowed) summary.allowed++
305
343
  else summary.denied++
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * 路径安全防护 — 防止路径遍历攻击和敏感文件访问
3
3
  * 对应原版: src/utils/permissions/filesystem.ts + 多处路径检查
4
+ *
5
+ * v1.1 修复:
6
+ * - /proc/self/ 加入禁止路径(容器逃逸/内存泄露)
7
+ * - 新增 URL 编码绕过检测(%2e%2e, %2f, %5c 等)
8
+ * - 双重编码绕过检测(%252e 等)
4
9
  */
5
10
  import { resolve, normalize, isAbsolute, relative, sep } from 'path'
11
+ import { realpathSync } from 'fs'
6
12
 
7
13
  /**
8
14
  * 敏感路径列表 — 禁止读写
@@ -16,6 +22,7 @@ const FORBIDDEN_PATHS = [
16
22
  '/etc/pam.d/',
17
23
  '/boot/',
18
24
  '/proc/sys/',
25
+ '/proc/self/', // v1.1: 阻止 /proc/self 访问(容器逃逸/内存泄露)
19
26
  '/sys/kernel/',
20
27
  ]
21
28
 
@@ -31,15 +38,32 @@ const SENSITIVE_PREFIXES = [
31
38
 
32
39
  /**
33
40
  * 规范化路径 — 解析 .., ., 符号链接等
41
+ * v1.1 修复: 增加编码绕过检测(%2e%2e, %252e 等 URL 编码变形)
34
42
  * @param {string} filePath — 输入路径
35
43
  * @param {string} cwd — 当前工作目录
36
44
  * @returns {string} 规范化后的绝对路径
37
45
  */
38
46
  export function sanitizePath(filePath, cwd = process.cwd()) {
47
+ // v1.1: 解码 URL 编码绕过(%2e = ., %2f = /, %5c = \)
48
+ let decoded = filePath
49
+ // 双重编码先解
50
+ decoded = decoded.replace(/%252e/gi, '.').replace(/%252f/gi, '/').replace(/%255c/gi, '\\')
51
+ // 单次编码
52
+ decoded = decoded.replace(/%2e/gi, '.').replace(/%2f/gi, '/').replace(/%5c/gi, '\\')
53
+
39
54
  // 如果是相对路径,基于 cwd 解析
40
- const absPath = isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
55
+ const absPath = isAbsolute(decoded) ? decoded : resolve(cwd, decoded)
41
56
  // 规范化:消除 .. 和 .
42
- return normalize(absPath)
57
+ const normalized = normalize(absPath)
58
+
59
+ // M6 fix: 解析符号链接,防止通过 symlink 绕过路径安全检查
60
+ // 例如: /tmp/link → /etc/shadow
61
+ try {
62
+ return realpathSync(normalized)
63
+ } catch {
64
+ // 文件不存在时 realpathSync 会抛错,返回规范化路径即可
65
+ return normalized
66
+ }
43
67
  }
44
68
 
45
69
  /**
@@ -52,7 +76,16 @@ export function sanitizePath(filePath, cwd = process.cwd()) {
52
76
  export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs = []) {
53
77
  const resolvedPath = sanitizePath(filePath, cwd)
54
78
 
55
- // 1. 检查 .. 在原始路径中的使用
79
+ // 1. v1.1: 检查原始输入中的编码绕过尝试
80
+ if (/%2e|%2f|%5c|%252e|%252f/i.test(filePath)) {
81
+ return {
82
+ safe: false,
83
+ resolvedPath,
84
+ reason: `路径编码绕过检测: ${filePath} 包含 URL 编码字符,疑似路径遍历攻击`,
85
+ }
86
+ }
87
+
88
+ // 2. 检查 .. 在原始路径中的使用
56
89
  if (filePath.includes('..')) {
57
90
  const normalizedRelative = relative(cwd, resolvedPath)
58
91
  if (normalizedRelative.startsWith('..') || resolvedPath.startsWith('/etc/') || resolvedPath.startsWith('/root/')) {
@@ -64,13 +97,12 @@ export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs =
64
97
  }
65
98
  }
66
99
 
67
- // 2. 检查是否在允许的目录范围内
100
+ // 3. 检查是否在允许的目录范围内
68
101
  if (allowedDirs.length > 0) {
69
102
  const isInAllowedDir = allowedDirs.some(dir => {
70
103
  const normDir = normalize(isAbsolute(dir) ? dir : resolve(cwd, dir))
71
104
  return resolvedPath.startsWith(normDir + sep) || resolvedPath === normDir
72
105
  })
73
-
74
106
  if (!isInAllowedDir) {
75
107
  return {
76
108
  safe: false,
@@ -79,7 +111,6 @@ export function checkPathTraversal(filePath, cwd = process.cwd(), allowedDirs =
79
111
  }
80
112
  }
81
113
  }
82
-
83
114
  return { safe: true, resolvedPath }
84
115
  }
85
116
 
@@ -92,41 +123,26 @@ export function checkForbiddenPath(resolvedPath) {
92
123
  // 1. 严格匹配禁止路径
93
124
  for (const forbidden of FORBIDDEN_PATHS) {
94
125
  if (resolvedPath === forbidden || resolvedPath.startsWith(forbidden + sep) || resolvedPath.startsWith(forbidden + '/')) {
95
- return {
96
- allowed: false,
97
- reason: `禁止访问敏感路径: ${resolvedPath}(匹配规则: ${forbidden})`,
98
- }
126
+ return { allowed: false, reason: `禁止访问敏感路径: ${resolvedPath}(匹配规则: ${forbidden})` }
99
127
  }
100
128
  }
101
129
 
102
130
  // 2. SSH 目录特殊处理
103
131
  if (resolvedPath.includes('/.ssh/') || resolvedPath.includes('\\.ssh\\')) {
104
- // 允许读取 known_hosts 和 config,禁止读取私钥
105
132
  const sshKeyPattern = /\/\.ssh\/id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i
106
133
  const sshConfigPattern = /\/\.ssh\/(config|known_hosts|authorized_keys)$/i
107
-
108
134
  if (sshKeyPattern.test(resolvedPath)) {
109
- return {
110
- allowed: false,
111
- reason: `禁止访问 SSH 密钥文件: ${resolvedPath}`,
112
- }
135
+ return { allowed: false, reason: `禁止访问 SSH 密钥文件: ${resolvedPath}` }
113
136
  }
114
-
115
137
  if (sshConfigPattern.test(resolvedPath)) {
116
- return {
117
- allowed: true,
118
- reason: `⚠️ 访问 SSH 配置文件: ${resolvedPath}`,
119
- }
138
+ return { allowed: true, reason: `⚠️ 访问 SSH 配置文件: ${resolvedPath}` }
120
139
  }
121
140
  }
122
141
 
123
142
  // 3. 敏感前缀检查 — 允许但提示
124
143
  for (const prefix of SENSITIVE_PREFIXES) {
125
144
  if (resolvedPath.startsWith(prefix)) {
126
- return {
127
- allowed: true,
128
- reason: `⚠️ 访问系统敏感目录: ${resolvedPath}`,
129
- }
145
+ return { allowed: true, reason: `⚠️ 访问系统敏感目录: ${resolvedPath}` }
130
146
  }
131
147
  }
132
148
 
@@ -143,7 +159,6 @@ export function checkPathSafety(filePath, options = {}) {
143
159
  const { cwd = process.cwd(), allowedDirs = [], checkForbidden = true } = options
144
160
  const reasons = []
145
161
 
146
- // 路径遍历检查
147
162
  const traversalResult = checkPathTraversal(filePath, cwd, allowedDirs)
148
163
  const resolvedPath = traversalResult.resolvedPath
149
164
 
@@ -151,7 +166,6 @@ export function checkPathSafety(filePath, options = {}) {
151
166
  reasons.push(traversalResult.reason)
152
167
  }
153
168
 
154
- // 禁止路径检查
155
169
  if (checkForbidden) {
156
170
  const forbiddenResult = checkForbiddenPath(resolvedPath)
157
171
  if (!forbiddenResult.allowed) {
@@ -162,7 +176,7 @@ export function checkPathSafety(filePath, options = {}) {
162
176
  }
163
177
 
164
178
  return {
165
- safe: !reasons.some(r => r.startsWith('禁止') || r.startsWith('路径遍历')),
179
+ safe: !reasons.some(r => r.startsWith('禁止') || r.startsWith('路径遍历') || r.startsWith('路径编码绕过')),
166
180
  resolvedPath,
167
181
  reasons,
168
182
  }
@@ -178,13 +192,12 @@ export function checkWritePathSafety(filePath, options = {}) {
178
192
  const result = checkPathSafety(filePath, options)
179
193
 
180
194
  // 写入额外检查:不能写到系统关键目录
181
- const systemWriteDirs = ['/etc/', '/boot/', '/usr/bin/', '/usr/lib/', '/sbin/', '/bin/']
195
+ const systemWriteDirs = ['/etc/', '/boot/', '/usr/bin/', '/usr/lib/', '/sbin/', '/bin/', '/proc/', '/sys/']
182
196
  for (const dir of systemWriteDirs) {
183
197
  if (result.resolvedPath.startsWith(dir)) {
184
198
  result.safe = false
185
199
  result.reasons.push(`禁止写入系统关键目录: ${dir}`)
186
200
  }
187
201
  }
188
-
189
202
  return result
190
203
  }