access-policy.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. // 文件访问策略:全局强制禁止 ∩ Profile 策略 ∪ 基线(docs/design/06-security.md §4,ADR-0010)
  2. import fs from 'node:fs'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import picomatch from 'picomatch'
  6. import {
  7. BUILTIN_GLOBAL_DENY,
  8. type AppSettings,
  9. type FileAccessPolicy,
  10. type KnowledgeRootDto,
  11. type PathCheck,
  12. type ResolvedAccessDto,
  13. } from '@agent-server/shared'
  14. import { communitySkillsDir, config, customSkillsDir } from '../config'
  15. import { expandPath, hasGlob, isWithin, realpathLoose } from '../lib/paths'
  16. import { codexHome } from '../providers/codex/cli'
  17. export type AccessOp = 'read' | 'write'
  18. export interface ResolvedAccess {
  19. mode: FileAccessPolicy['mode']
  20. workspaceDir: string
  21. readable: string[]
  22. writable: string[]
  23. /** 无条件禁止(内置 + 全局 + Profile),可以是路径或通配 */
  24. hardDeny: string[]
  25. /** 默认禁止、但可被更具体的允许路径重新打开(如其他会话的工作区) */
  26. softDeny: string[]
  27. }
  28. export type AccessDecision = { allowed: true } | { allowed: false; reason: string }
  29. /** restricted 模式下 Bash 沙箱额外屏蔽的大块目录 */
  30. const RESTRICTED_BASH_DENY = () => [config.home, '/srv', '/mnt', '/media', '/root']
  31. export function globalDenyList(settings: AppSettings): string[] {
  32. // Codex 的登录凭证位于服务专用的 CODEX_HOME(开发环境不在 ~ 下,需要单独列出)
  33. return [...BUILTIN_GLOBAL_DENY, ...settings.security.globalDeny, path.join(codexHome(), 'auth.json')].map(expandPath)
  34. }
  35. export function resolveAccess(opts: {
  36. policy: FileAccessPolicy
  37. knowledgeRoots: KnowledgeRootDto[]
  38. selectedKnowledgeRootIds: string[]
  39. workspaceDir: string
  40. settings: AppSettings
  41. }): ResolvedAccess {
  42. const { policy, settings } = opts
  43. const roots = new Map(opts.knowledgeRoots.map(r => [r.id, r]))
  44. const readable = new Set<string>()
  45. const writable = new Set<string>()
  46. // 基线:工作区读写(含 Claude CLI 为该工作目录分配的临时目录);运行环境与技能只读
  47. writable.add(opts.workspaceDir)
  48. writable.add(claudeScratchDir(opts.workspaceDir))
  49. readable.add(settings.paths.pythonRuntime)
  50. readable.add(settings.paths.matplotlibrc)
  51. readable.add(settings.paths.nodeModules)
  52. readable.add(settings.paths.skillsDir)
  53. readable.add(communitySkillsDir())
  54. // 只开放生效目录;版本库(custom-skill-store)在服务数据目录中,不可见
  55. readable.add(customSkillsDir())
  56. for (const id of opts.selectedKnowledgeRootIds) {
  57. const root = roots.get(id)
  58. if (!root?.enabled) continue
  59. const target = root.access === 'write' ? writable : readable
  60. target.add(expandPath(root.path))
  61. }
  62. for (const rule of policy.read) {
  63. const p = ruleToPath(rule, roots)
  64. if (p) readable.add(p)
  65. }
  66. for (const rule of policy.write) {
  67. const p = ruleToPath(rule, roots)
  68. if (p) writable.add(p)
  69. }
  70. if (policy.mode === 'broad') readable.add('/')
  71. return {
  72. mode: policy.mode,
  73. workspaceDir: opts.workspaceDir,
  74. readable: [...readable].map(p => realpathLoose(p)),
  75. writable: [...writable].map(p => realpathLoose(p)),
  76. hardDeny: [...globalDenyList(settings), ...policy.deny.map(expandPath)],
  77. softDeny: [realpathLoose(settings.paths.workspaceRoot), realpathLoose(config.dataDir)],
  78. }
  79. }
  80. /** Claude CLI 的会话临时目录:<tmp>/claude-<uid>/<工作目录路径中非字母数字替换为 -> */
  81. export function claudeScratchDir(workspaceDir: string): string {
  82. return path.join(os.tmpdir(), `claude-${process.getuid?.() ?? 0}`, workspaceDir.replace(/[^a-zA-Z0-9]/g, '-'))
  83. }
  84. function ruleToPath(rule: FileAccessPolicy['read'][number], roots: Map<string, KnowledgeRootDto>): string | null {
  85. if ('path' in rule) return expandPath(rule.path)
  86. const root = roots.get(rule.knowledgeRootId)
  87. return root?.enabled ? expandPath(root.path) : null
  88. }
  89. const matcherCache = new Map<string, (p: string) => boolean>()
  90. /** 路径是否命中全局禁止项(内置 + 设置中追加的);后台文件管理也遵守 */
  91. export function isGloballyDenied(target: string, settings: AppSettings): boolean {
  92. const real = realpathLoose(target)
  93. return globalDenyList(settings).some(e => matchesEntry(real, e))
  94. }
  95. function matchesEntry(target: string, entry: string): boolean {
  96. if (!hasGlob(entry)) return isWithin(target, entry)
  97. let m = matcherCache.get(entry)
  98. if (!m) {
  99. // 相对模式在任何位置匹配:不含 / 时按文件名匹配,否则匹配去掉开头 / 的路径
  100. const relative = !path.isAbsolute(entry)
  101. const pm = picomatch(entry, { dot: true, basename: relative && !entry.includes('/') })
  102. m = relative ? (p: string) => pm(p.slice(1)) : pm
  103. matcherCache.set(entry, m)
  104. }
  105. // 通配命中目录本身或其任一祖先,都视为命中
  106. for (let p = target; ; p = path.dirname(p)) {
  107. if (m(p)) return true
  108. if (path.dirname(p) === p) return false
  109. }
  110. }
  111. /** 沙箱配置只接受锚定路径:把相对通配挂到每个可访问根目录下 */
  112. function anchorGlobs(entries: string[], roots: string[]): string[] {
  113. return entries.flatMap(e => (path.isAbsolute(e) ? [e] : roots.map(r => path.join(r, e.startsWith('**/') ? e : `**/${e}`))))
  114. }
  115. /** 返回 list 中包含 target 的最具体(最长)路径;通配项不参与 */
  116. function mostSpecific(target: string, list: string[]): string | null {
  117. let best: string | null = null
  118. for (const p of list) if (!hasGlob(p) && isWithin(target, p) && (!best || p.length > best.length)) best = p
  119. return best
  120. }
  121. /**
  122. * 服务管理的技能生效目录(第三方、自定义):位于全局禁止的 {dataDir}/data 之内,但必须只读可见,
  123. * 否则 Agent 读不到技能的参考文档、运行不了脚本。只放开这两个目录,data 下的其他内容(数据库、版本库、MCP 数据)仍然禁止
  124. */
  125. export const managedSkillRoots = () => [communitySkillsDir(), customSkillsDir()].map(realpathLoose)
  126. export function checkAccess(access: ResolvedAccess, rawPath: string, op: AccessOp): AccessDecision {
  127. const target = realpathLoose(rawPath)
  128. if (op === 'read' && managedSkillRoots().some(r => isWithin(target, r))) return { allowed: true }
  129. for (const entry of access.hardDeny) {
  130. if (matchesEntry(target, entry)) return { allowed: false, reason: `路径被禁止访问:${entry}` }
  131. }
  132. const allowList = op === 'write' ? access.writable : [...access.readable, ...access.writable]
  133. const allow = mostSpecific(target, allowList)
  134. if (!allow) {
  135. return { allowed: false, reason: op === 'write' ? `不允许写入工作区以外的路径:${target}` : `路径不在可读范围内:${target}` }
  136. }
  137. const soft = mostSpecific(target, access.softDeny)
  138. if (soft && soft.length > allow.length) return { allowed: false, reason: `路径被禁止访问:${soft}` }
  139. return { allowed: true }
  140. }
  141. /** 把合并后的策略映射为 Claude Bash 沙箱设置(06 §4.2) */
  142. export function toClaudeSandbox(access: ResolvedAccess, settings: AppSettings) {
  143. const readable = access.readable.filter(p => p !== '/')
  144. const hardDeny = anchorGlobs(access.hardDeny, [...readable, ...access.writable])
  145. const denyRead = [...hardDeny, ...(access.mode === 'restricted' ? RESTRICTED_BASH_DENY() : access.softDeny)]
  146. return {
  147. enabled: true,
  148. failIfUnavailable: true,
  149. allowUnsandboxedCommands: false,
  150. autoAllowBashIfSandboxed: true,
  151. filesystem: {
  152. // 注意:沙箱禁止创建 Unix socket,LibreOffice 无法在 Bash 中运行,改由 convert_document 工具提供(services/office.ts)
  153. allowWrite: access.writable,
  154. denyWrite: hardDeny,
  155. denyRead,
  156. allowRead: [...readable, ...access.writable],
  157. },
  158. network: { allowedDomains: settings.network.allowedDomains },
  159. }
  160. }
  161. export function toResolvedDto(access: ResolvedAccess): ResolvedAccessDto {
  162. return {
  163. mode: access.mode,
  164. readable: access.readable,
  165. writable: access.writable,
  166. denied: [...access.hardDeny, ...access.softDeny.map(p => `${p}(其他会话 / 服务数据,工作区除外)`)],
  167. }
  168. }
  169. /** 通用路径检查(系统设置中的路径):只看是否存在、可读 */
  170. export function checkPathBasic(raw: string): PathCheck {
  171. const p = expandPath(raw)
  172. const result: PathCheck = { exists: false, readable: false, isDirectory: false, denied: null, underHome: isWithin(p, config.home), message: null }
  173. try {
  174. const st = fs.statSync(p)
  175. result.exists = true
  176. result.isDirectory = st.isDirectory()
  177. fs.accessSync(p, fs.constants.R_OK)
  178. result.readable = true
  179. } catch {
  180. result.message = result.exists ? '服务进程没有读取权限' : '路径不存在'
  181. }
  182. return result
  183. }
  184. export function checkKnowledgePath(raw: string, settings: AppSettings): PathCheck {
  185. const p = expandPath(raw)
  186. const result: PathCheck = { exists: false, readable: false, isDirectory: false, denied: null, underHome: isWithin(p, config.home), message: null }
  187. for (const entry of globalDenyList(settings)) {
  188. if (matchesEntry(p, entry) || (!hasGlob(entry) && isWithin(entry, p))) {
  189. result.denied = entry
  190. result.message = `与全局禁止项冲突:${entry}`
  191. return result
  192. }
  193. }
  194. if (isWithin(p, realpathLoose(settings.paths.workspaceRoot))) {
  195. result.message = '不能把会话工作区目录设为资料库'
  196. return result
  197. }
  198. try {
  199. const st = fs.statSync(p)
  200. result.exists = true
  201. result.isDirectory = st.isDirectory()
  202. fs.accessSync(p, fs.constants.R_OK)
  203. result.readable = true
  204. } catch {
  205. result.message ??= result.exists ? '服务进程没有读取权限' : '路径不存在'
  206. }
  207. if (result.exists && !result.isDirectory) result.message = '资料库必须是目录'
  208. if (!result.message && result.underHome) {
  209. result.message = `位于 home 目录下;以 systemd 加固方式部署时需要在 unit 中追加 BindReadOnlyPaths=${p}`
  210. }
  211. return result
  212. }