gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7f9660ee7f9660eeState of 2026-09-27, before the move to gitoriamre7f9660ee/lib/sandbox.hl

6.7 KB

  1. // sandbox.hl — antcolony#18 "Each worker in its own sealed box".
  2. //
  3. // A Claude session (worker, finalize, resume, controller) runs inside a bubblewrap (`bwrap`) box instead of with the
  4. // host user's whole filesystem. The box is an ALLOWLIST: the system (ro), the claude binary + login (its config folder),
  5. // the project's dev folder (rw), the dev folders of the projects it depends on + the concept docs (ro), a private /tmp.
  6. // Everything else on the host — other projects, ~/.config/antcolony (tokens), ~/.ssh, other users' files, `.env` files,
  7. // the scheduler's runs/logs/sessions — is not there. The network is shared (Claude API, the tickets copy, dev servers on
  8. // the session's ports).
  9. //
  10. // DEPLOY RIGHTS: only a ticket marked for deploy gets more — the project's live folder (rw) and ~/.ssh (ro). The mark is a
  11. // line `colony-deploy: yes` in the ticket's opening text or in a comment of the creator (`deployMarked`).
  12. //
  13. // COLONY_SANDBOX = on (default) | off (off = the old behaviour, host access; the e2e uses it for the fake claude)
  14. // COLONY_SANDBOX_RO = extra read-only paths, `:`-separated (default: the docs folder /media/STORAGE/projects/antcolony-docs)
  15. import { env } from 'hl:proc'
  16. import { exists, listDir } from 'hl:fs'
  17. import { loadRegistry } from './registry.hl'
  18. import { isCreatorEvent } from './tickets.hl'
  19. import { NL } from './util.hl'
  20. static BWRAP = '/usr/bin/bwrap'
  21. static DEFAULT_RO = '/media/STORAGE/projects/antcolony-docs'
  22. static MARK = 'colony-deploy: yes'
  23. static sandboxOn = () => {
  24. let v = env('COLONY_SANDBOX')
  25. return !(v == 'off' || v == '0' || v == 'no' || v == 'false')
  26. }
  27. // is a ticket marked for deploy? ticket = the row (its summary is the opening text), events = its history; a mark counts
  28. // from the opening text or from a comment of the creator (a worker can never write a comment itself — the scheduler posts what it reports, and
  29. // that text is not looked at).
  30. static hasMark = (text) => {
  31. if (text == null) { return false }
  32. for (line of text.split(NL)) { if (line.trim() == MARK) { return true } }
  33. return false
  34. }
  35. static deployMarked = (project, ticket, events, creatorNames) => {
  36. if (ticket != null && hasMark(ticket.summary)) { return true }
  37. if (events == null) { return false }
  38. for (e of events) {
  39. if (e.kind == 'comment' && isCreatorEvent(project, e, creatorNames) && hasMark(e.text)) { return true }
  40. }
  41. return false
  42. }
  43. // a dev/live folder of a project that lives on THIS host → its path, else null
  44. static localFolder = (where, host) => {
  45. if (where == null || where.folder == null || where.folder == '') { return null }
  46. if (where.host != null && where.host != '' && where.host.toLowerCase() != host.toLowerCase()) { return null }
  47. return exists(where.folder) ? where.folder : null
  48. }
  49. // the absolute paths a concept text names (`loreana:/media/x/README.md + docs/y.md + /media/x/z.md`)
  50. static conceptPaths = (concept) => {
  51. let out = []
  52. if (concept == null) { return out }
  53. for (w of concept.split(' ')) {
  54. let t = w.startsWith('loreana:') ? w.slice(8) : w
  55. if (t.startsWith('/') && exists(t)) { out.push(t) }
  56. }
  57. return out
  58. }
  59. static add2 = (a, x, y) => {
  60. a.push(x)
  61. a.push(y)
  62. return a
  63. }
  64. static add3 = (a, x, y, z) => {
  65. a.push(x)
  66. a.push(y)
  67. a.push(z)
  68. return a
  69. }
  70. static tmpfs = (a, path) => {
  71. a.push('--tmpfs')
  72. a.push(path)
  73. return a
  74. }
  75. static bindRo = (a, path) => { return add3(a, '--ro-bind-try', path, path) }
  76. static bindRw = (a, path) => { return add3(a, '--bind-try', path, path) }
  77. // `.env*` files (not the examples) directly inside a folder are replaced by an empty file
  78. static maskEnvFiles = (a, folder) => {
  79. for (n of listDir(folder)) {
  80. let f = hlTypeName(n) == 'String' ? n : n.name
  81. if (f == '.env' || (f.startsWith('.env.') && !f.endsWith('.example'))) {
  82. add3(a, '--ro-bind', '/dev/null', folder + '/' + f)
  83. }
  84. }
  85. }
  86. // → { args: [bwrap … --], error: null } — the argv prefix before the command; args = [] when the sandbox is off.
  87. // o = { project, folder (the session's cwd = the dev folder), host, deploy (bool), extraRo: [paths], ports? }
  88. static sandboxFor = (o) => {
  89. if (!sandboxOn()) { return { args = [] error = null } }
  90. if (!exists(BWRAP)) { return { args = [] error = 'the sandbox needs ' + BWRAP + ' (bubblewrap) — install it, or set COLONY_SANDBOX=off to run without a box' } }
  91. let home = env('HOME')
  92. if (home == null || home == '') { return { args = [] error = 'the sandbox needs HOME' } }
  93. let cfg = env('CLAUDE_CONFIG_DIR')
  94. let reg = loadRegistry()
  95. let m = reg.projects[o.project]
  96. let a = [BWRAP '--die-with-parent' '--unshare-pid' '--unshare-ipc' '--unshare-uts' '--new-session']
  97. // the system, read-only
  98. for (p of ['/usr' '/bin' '/lib' '/lib64' '/sbin' '/etc' '/opt']) { bindRo(a, p) }
  99. bindRo(a, '/sys')
  100. a.push('--proc')
  101. a.push('/proc')
  102. a.push('--dev')
  103. a.push('/dev')
  104. for (p of ['/tmp' '/var' '/run']) { tmpfs(a, p) }
  105. bindRo(a, '/run/systemd/resolve') // /etc/resolv.conf points there (DNS)
  106. // home: empty, then only what claude and the toolchain need
  107. tmpfs(a, home)
  108. for (p of [home + '/.local/bin' home + '/.local/share/claude' home + '/.hybriel' home + '/.gitconfig']) { bindRo(a, p) }
  109. if (cfg != null && cfg != '') {
  110. bindRw(a, cfg)
  111. } else {
  112. bindRw(a, home + '/.claude')
  113. bindRw(a, home + '/.claude.json')
  114. }
  115. // projects: an empty /media, then the project's own folder (rw), what it depends on + the concept docs (ro)
  116. tmpfs(a, '/media')
  117. let ro = []
  118. if (m != null) {
  119. for (d of m.deps) {
  120. let dm = reg.projects[d]
  121. let f = dm == null ? null : localFolder(dm.dev, o.host)
  122. if (f != null && f != o.folder) { ro.push(f) }
  123. }
  124. for (c of conceptPaths(m.concept)) { if (c != o.folder && !c.startsWith(o.folder + '/')) { ro.push(c) } }
  125. }
  126. let extra = env('COLONY_SANDBOX_RO')
  127. for (x of (extra == null || extra == '' ? DEFAULT_RO : extra).split(':')) { if (x != '' && exists(x)) { ro.push(x) } }
  128. if (o.extraRo != null) { for (x of o.extraRo) { ro.push(x) } }
  129. add3(a, '--bind', o.folder, o.folder)
  130. maskEnvFiles(a, o.folder)
  131. // the scheduler working on itself: no sight of other projects' runs, logs, sessions (briefs, reports, tokens' names)
  132. let self = env('COLONY_HOME')
  133. if (self != null && self == o.folder) {
  134. for (n of ['runs' 'logs' 'sessions' 'briefs']) { if (exists(self + '/' + n)) { tmpfs(a, self + '/' + n) } }
  135. }
  136. for (x of ro) { bindRo(a, x) }
  137. // a dependency's folder is read-only, but its secrets are not even there
  138. for (x of ro) { if (exists(x + '/.env') || exists(x + '/.env.local')) { maskEnvFiles(a, x) } }
  139. // deploy rights: only a ticket marked for deploy
  140. if (o.deploy == true) {
  141. let live = m == null ? null : localFolder(m.live, o.host)
  142. if (live != null && live != o.folder) { bindRw(a, live) }
  143. bindRo(a, home + '/.ssh')
  144. }
  145. for (n of ['COLONY_TOKEN_FILE' 'COLONY_CREATOR_TOKEN_FILE' 'COLONY_AGENT_TOKEN_FILE' 'COLONY_STOP_FILE']) { add2(a, '--unsetenv', n) }
  146. add2(a, '--chdir', o.folder)
  147. a.push('--')
  148. return { args = a error = null }
  149. }

Branches

Latest commits

  • 7f9660eeState of 2026-09-27, before the move to gitoriamre