antcolony
All repositories: gitoria
6.7 KB
// sandbox.hl — antcolony#18 "Each worker in its own sealed box".//// A Claude session (worker, finalize, resume, controller) runs inside a bubblewrap (`bwrap`) box instead of with the// host user's whole filesystem. The box is an ALLOWLIST: the system (ro), the claude binary + login (its config folder),// the project's dev folder (rw), the dev folders of the projects it depends on + the concept docs (ro), a private /tmp.// Everything else on the host — other projects, ~/.config/antcolony (tokens), ~/.ssh, other users' files, `.env` files,// the scheduler's runs/logs/sessions — is not there. The network is shared (Claude API, the tickets copy, dev servers on// the session's ports).//// DEPLOY RIGHTS: only a ticket marked for deploy gets more — the project's live folder (rw) and ~/.ssh (ro). The mark is a// line `colony-deploy: yes` in the ticket's opening text or in a comment of the creator (`deployMarked`).//// COLONY_SANDBOX = on (default) | off (off = the old behaviour, host access; the e2e uses it for the fake claude)// COLONY_SANDBOX_RO = extra read-only paths, `:`-separated (default: the docs folder /media/STORAGE/projects/antcolony-docs)import { env } from 'hl:proc'import { exists, listDir } from 'hl:fs'import { loadRegistry } from './registry.hl'import { isCreatorEvent } from './tickets.hl'import { NL } from './util.hl'static BWRAP = '/usr/bin/bwrap'static DEFAULT_RO = '/media/STORAGE/projects/antcolony-docs'static MARK = 'colony-deploy: yes'static sandboxOn = () => {let v = env('COLONY_SANDBOX')return !(v == 'off' || v == '0' || v == 'no' || v == 'false')}// is a ticket marked for deploy? ticket = the row (its summary is the opening text), events = its history; a mark counts// 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// that text is not looked at).static hasMark = (text) => {if (text == null) { return false }for (line of text.split(NL)) { if (line.trim() == MARK) { return true } }return false}static deployMarked = (project, ticket, events, creatorNames) => {if (ticket != null && hasMark(ticket.summary)) { return true }if (events == null) { return false }for (e of events) {if (e.kind == 'comment' && isCreatorEvent(project, e, creatorNames) && hasMark(e.text)) { return true }}return false}// a dev/live folder of a project that lives on THIS host → its path, else nullstatic localFolder = (where, host) => {if (where == null || where.folder == null || where.folder == '') { return null }if (where.host != null && where.host != '' && where.host.toLowerCase() != host.toLowerCase()) { return null }return exists(where.folder) ? where.folder : null}// the absolute paths a concept text names (`loreana:/media/x/README.md + docs/y.md + /media/x/z.md`)static conceptPaths = (concept) => {let out = []if (concept == null) { return out }for (w of concept.split(' ')) {let t = w.startsWith('loreana:') ? w.slice(8) : wif (t.startsWith('/') && exists(t)) { out.push(t) }}return out}static add2 = (a, x, y) => {a.push(x)a.push(y)return a}static add3 = (a, x, y, z) => {a.push(x)a.push(y)a.push(z)return a}static tmpfs = (a, path) => {a.push('--tmpfs')a.push(path)return a}static bindRo = (a, path) => { return add3(a, '--ro-bind-try', path, path) }static bindRw = (a, path) => { return add3(a, '--bind-try', path, path) }// `.env*` files (not the examples) directly inside a folder are replaced by an empty filestatic maskEnvFiles = (a, folder) => {for (n of listDir(folder)) {let f = hlTypeName(n) == 'String' ? n : n.nameif (f == '.env' || (f.startsWith('.env.') && !f.endsWith('.example'))) {add3(a, '--ro-bind', '/dev/null', folder + '/' + f)}}}// → { args: [bwrap … --], error: null } — the argv prefix before the command; args = [] when the sandbox is off.// o = { project, folder (the session's cwd = the dev folder), host, deploy (bool), extraRo: [paths], ports? }static sandboxFor = (o) => {if (!sandboxOn()) { return { args = [] error = null } }if (!exists(BWRAP)) { return { args = [] error = 'the sandbox needs ' + BWRAP + ' (bubblewrap) — install it, or set COLONY_SANDBOX=off to run without a box' } }let home = env('HOME')if (home == null || home == '') { return { args = [] error = 'the sandbox needs HOME' } }let cfg = env('CLAUDE_CONFIG_DIR')let reg = loadRegistry()let m = reg.projects[o.project]let a = [BWRAP '--die-with-parent' '--unshare-pid' '--unshare-ipc' '--unshare-uts' '--new-session']// the system, read-onlyfor (p of ['/usr' '/bin' '/lib' '/lib64' '/sbin' '/etc' '/opt']) { bindRo(a, p) }bindRo(a, '/sys')a.push('--proc')a.push('/proc')a.push('--dev')a.push('/dev')for (p of ['/tmp' '/var' '/run']) { tmpfs(a, p) }bindRo(a, '/run/systemd/resolve') // /etc/resolv.conf points there (DNS)// home: empty, then only what claude and the toolchain needtmpfs(a, home)for (p of [home + '/.local/bin' home + '/.local/share/claude' home + '/.hybriel' home + '/.gitconfig']) { bindRo(a, p) }if (cfg != null && cfg != '') {bindRw(a, cfg)} else {bindRw(a, home + '/.claude')bindRw(a, home + '/.claude.json')}// projects: an empty /media, then the project's own folder (rw), what it depends on + the concept docs (ro)tmpfs(a, '/media')let ro = []if (m != null) {for (d of m.deps) {let dm = reg.projects[d]let f = dm == null ? null : localFolder(dm.dev, o.host)if (f != null && f != o.folder) { ro.push(f) }}for (c of conceptPaths(m.concept)) { if (c != o.folder && !c.startsWith(o.folder + '/')) { ro.push(c) } }}let extra = env('COLONY_SANDBOX_RO')for (x of (extra == null || extra == '' ? DEFAULT_RO : extra).split(':')) { if (x != '' && exists(x)) { ro.push(x) } }if (o.extraRo != null) { for (x of o.extraRo) { ro.push(x) } }add3(a, '--bind', o.folder, o.folder)maskEnvFiles(a, o.folder)// the scheduler working on itself: no sight of other projects' runs, logs, sessions (briefs, reports, tokens' names)let self = env('COLONY_HOME')if (self != null && self == o.folder) {for (n of ['runs' 'logs' 'sessions' 'briefs']) { if (exists(self + '/' + n)) { tmpfs(a, self + '/' + n) } }}for (x of ro) { bindRo(a, x) }// a dependency's folder is read-only, but its secrets are not even therefor (x of ro) { if (exists(x + '/.env') || exists(x + '/.env.local')) { maskEnvFiles(a, x) } }// deploy rights: only a ticket marked for deployif (o.deploy == true) {let live = m == null ? null : localFolder(m.live, o.host)if (live != null && live != o.folder) { bindRw(a, live) }bindRo(a, home + '/.ssh')}for (n of ['COLONY_TOKEN_FILE' 'COLONY_CREATOR_TOKEN_FILE' 'COLONY_AGENT_TOKEN_FILE' 'COLONY_STOP_FILE']) { add2(a, '--unsetenv', n) }add2(a, '--chdir', o.folder)a.push('--')return { args = a error = null }}
Branches
- mainmain branch
Latest commits
- a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
- c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
- 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
- 7f9660eeState of 2026-09-27, before the move to gitoriamre