access-policy.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import fs from 'node:fs'
  2. import os from 'node:os'
  3. import path from 'node:path'
  4. import { describe, expect, it } from 'vitest'
  5. import type { FileAccessPolicy, KnowledgeRootDto } from '@agent-server/shared'
  6. import { config, mounterDir } from '../src/config'
  7. import { checkAccess, resolveAccess, toClaudeSandbox } from '../src/services/access-policy'
  8. import { defaultSettings } from '../src/services/settings'
  9. const settings = defaultSettings()
  10. const ws = path.join(settings.paths.workspaceRoot, 'ses_a')
  11. const other = path.join(settings.paths.workspaceRoot, 'ses_b')
  12. fs.mkdirSync(path.join(ws, 'uploads'), { recursive: true })
  13. fs.mkdirSync(other, { recursive: true })
  14. const kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
  15. const kbRoot = (over: Partial<KnowledgeRootDto>): KnowledgeRootDto => ({
  16. id: 'kb_1', name: '资料', kind: 'managed', path: kbDir, description: '', access: 'read', enabled: true, url: null, local: null, remote: null,
  17. status: { state: 'ok', message: null, since: null }, collectionIds: [], ...over,
  18. })
  19. const mountDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-mnt-'))
  20. const roots = [
  21. kbRoot({}),
  22. kbRoot({ id: 'kb_rw', path: path.join(kbDir, 'rw'), access: 'write' }),
  23. // 本机目录即使数据被改成 write 也只读
  24. kbRoot({ id: 'kb_local', kind: 'local', path: mountDir, access: 'write', local: { path: '/home/u/docs', uuid: null, relPath: null } }),
  25. kbRoot({ id: 'kb_link', kind: 'link', path: '', url: 'http://wiki.local/a' }),
  26. ]
  27. const policy = (p: Partial<FileAccessPolicy> = {}): FileAccessPolicy => ({ mode: 'restricted', read: [], write: [], deny: [], ...p })
  28. const resolve = (p: Partial<FileAccessPolicy> = {}, kbIds: string[] = []) =>
  29. resolveAccess({ policy: policy(p), knowledgeRoots: roots, selectedKnowledgeRootIds: kbIds, workspaceDir: ws, settings })
  30. describe('checkAccess', () => {
  31. it('允许读写自己的工作区', () => {
  32. const a = resolve()
  33. expect(checkAccess(a, path.join(ws, 'uploads/x.csv'), 'read').allowed).toBe(true)
  34. expect(checkAccess(a, path.join(ws, 'outputs/new.docx'), 'write').allowed).toBe(true)
  35. })
  36. it('restricted 模式拒绝工作区以外的读取', () => {
  37. expect(checkAccess(resolve(), '/etc/hostname', 'read').allowed).toBe(false)
  38. })
  39. it('始终拒绝 CLI 凭证,即使是 broad 模式', () => {
  40. const a = resolve({ mode: 'broad' })
  41. expect(checkAccess(a, path.join(config.home, '.claude/.credentials.json'), 'read').allowed).toBe(false)
  42. expect(checkAccess(a, path.join(config.home, '.codex/auth.json'), 'read').allowed).toBe(false)
  43. expect(checkAccess(a, path.join(config.home, '.ssh/id_ed25519'), 'read').allowed).toBe(false)
  44. })
  45. it('broad 模式可读系统文件,但不能读其他会话的工作区和服务数据库', () => {
  46. const a = resolve({ mode: 'broad' })
  47. expect(checkAccess(a, '/etc/hostname', 'read').allowed).toBe(true)
  48. expect(checkAccess(a, path.join(other, 'secret.txt'), 'read').allowed).toBe(false)
  49. expect(checkAccess(a, path.join(config.dataDir, 'data/agent.db'), 'read').allowed).toBe(false)
  50. expect(checkAccess(a, '/etc/hostname', 'write').allowed).toBe(false)
  51. })
  52. it('运行环境只读', () => {
  53. const a = resolve()
  54. expect(checkAccess(a, path.join(settings.paths.pythonRuntime, 'bin/python'), 'read').allowed).toBe(true)
  55. expect(checkAccess(a, path.join(settings.paths.pythonRuntime, 'bin/python'), 'write').allowed).toBe(false)
  56. })
  57. it('资料库只有在 Profile 启用后才可读', () => {
  58. expect(checkAccess(resolve(), path.join(kbDir, 'a.pdf'), 'read').allowed).toBe(false)
  59. expect(checkAccess(resolve({}, ['kb_1']), path.join(kbDir, 'a.pdf'), 'read').allowed).toBe(true)
  60. expect(checkAccess(resolve({}, ['kb_1']), path.join(kbDir, 'a.pdf'), 'write').allowed).toBe(false)
  61. })
  62. it('只有托管目录可以写;本机目录只读;网络链接不开放任何路径', () => {
  63. const a = resolve({}, ['kb_rw', 'kb_local', 'kb_link'])
  64. expect(checkAccess(a, path.join(kbDir, 'rw/new.md'), 'write').allowed).toBe(true)
  65. expect(checkAccess(a, path.join(mountDir, 'a.md'), 'read').allowed).toBe(true)
  66. expect(checkAccess(a, path.join(mountDir, 'a.md'), 'write').allowed).toBe(false)
  67. expect(a.readable).not.toContain('')
  68. // 按资料库设置的写规则也只对可写的托管目录生效
  69. const rules = resolve({ write: [{ knowledgeRootId: 'kb_local' }, { knowledgeRootId: 'kb_1' }, { knowledgeRootId: 'kb_rw' }], read: [{ knowledgeRootId: 'kb_link' }] })
  70. expect(checkAccess(rules, path.join(mountDir, 'a.md'), 'write').allowed).toBe(false)
  71. expect(checkAccess(rules, path.join(kbDir, 'a.md'), 'write').allowed).toBe(false)
  72. expect(checkAccess(rules, path.join(kbDir, 'rw/x.md'), 'write').allowed).toBe(true)
  73. })
  74. it('挂载服务的目录(socket、密钥、凭据)始终禁止,即使是 broad 模式', () => {
  75. expect(checkAccess(resolve({ mode: 'broad' }), path.join(mounterDir(), 'token'), 'read').allowed).toBe(false)
  76. })
  77. it('Profile 的通配禁止项生效', () => {
  78. const a = resolve({ deny: ['**/*.env'] })
  79. expect(checkAccess(a, path.join(ws, 'config/.env'), 'read').allowed).toBe(false)
  80. expect(checkAccess(a, path.join(ws, 'data.csv'), 'read').allowed).toBe(true)
  81. })
  82. it('拒绝通过软链接逃逸工作区', () => {
  83. const link = path.join(ws, 'escape')
  84. fs.rmSync(link, { force: true })
  85. fs.symlinkSync('/etc', link)
  86. expect(checkAccess(resolve(), path.join(link, 'hostname'), 'read').allowed).toBe(false)
  87. })
  88. it('拒绝 .. 路径穿越', () => {
  89. expect(checkAccess(resolve(), path.join(ws, '../ses_b/secret.txt'), 'read').allowed).toBe(false)
  90. })
  91. })
  92. describe('toClaudeSandbox', () => {
  93. it('restricted 模式屏蔽 home 并重新开放工作区', () => {
  94. const sb = toClaudeSandbox(resolve(), settings)
  95. expect(sb.failIfUnavailable).toBe(true)
  96. expect(sb.allowUnsandboxedCommands).toBe(false)
  97. expect(sb.filesystem.denyRead).toContain(config.home)
  98. expect(sb.filesystem.allowRead).toContain(fs.realpathSync(ws))
  99. expect(sb.filesystem.allowWrite).toContain(fs.realpathSync(ws))
  100. expect(sb.filesystem.allowWrite).not.toContain(config.home)
  101. })
  102. })