Cherry-pick of the mac_do/mdo feasibility analysis that lived on origin/multitenant-zai. Lands as documented context, not as an execution path: the doc's own conclusion is "do not replace sudo with mac_do now, finish the hostd migration instead." Why land it now: the queue-fix sequence test exposed that the chat agent hits the "needs root for pkg/freebsd-update audit" wall. The natural temptation when seeing that wall is to reach for a sudo replacement. This doc is the guardrail that says no — the gap is missing hostd operations, not a wrong privilege model. Concrete forward direction the doc supports: - Don't add mac_do/mdo to runtime paths. - Treat "can't inspect updates without root" as a missing-hostd-op signal, not a sudo problem. - Future hostd work should add operations for host package updates, pkg audit, jail package update checks, and freebsd-update fetch status — same pattern as the existing service-status/start/stop authorization flow. mac_do remains interesting for narrow read-only audit operators in a later slice, per section 5.3 of the doc. No embeddings refresh. Skills artifact stays where it is. --- Build: FAIL | Tests: FAIL — 16 failed
12 KiB
Sudo Replacement: mac_do / mdo Feasibility Analysis
Date: 01.maj.2026 Author: Linux Claude agent (research, no runtime validation) Status: Analysis complete — recommendation: do not replace, finish hostd migration instead
1. What mac_do Is
mac_do is a FreeBSD Mandatory Access Control (MAC) policy module (base system, FreeBSD 14.2+) that allows unprivileged users to change process credentials according to kernel-enforced rules. The companion userland tool is mdo(1).
Key properties:
- Kernel-enforced: rules live in sysctl/jail parameters, not in a userland config file parsed by a setuid binary
- No password: purely role-based, no PAM, no authentication prompt
- Atomic credential change: uses
setcred(2)to set all UIDs/GIDs/supplementary groups at once - Per-jail configuration:
mac.do.rulesjail parameter for jail-scoped rules - In base system: no external package dependency
Syntax Overview
Rules are strings of the form uid=X>uid=Y governing which credential transitions are allowed:
# Allow UID 1001 to become root
uid=1001>uid=0
# Allow UID 1001 to become UID 1002, keeping current supplementary groups
uid=1001>uid=1002,gid=1002,+gid=.
# Allow members of group 10001 to become root
gid=10001>uid=0
Loaded via sysctl (security.mac.do.rules) or jail parameter (mac.do.rules).
mdo(1) Usage
mdo -u root bastille cmd dbjail "pkg install -y postgresql16-server"
mdo -u postgres psql -d clawdie_brain -c "SELECT 1"
mdo -k -s +wheel /usr/bin/id
2. Current sudo Usage Audit
Full codebase audit found sudo referenced in ~10 runtime categories across ~100+ files.
2.1 Runtime Code (Programmatic Execution)
These files actually execute sudo at runtime:
| Category | Command Pattern | Files |
|---|---|---|
| Bastille jail mgmt | spawnSync('sudo', ['bastille', 'cmd', ...]) |
src/jail-exec-runner.ts:162,239 |
| Bastille listing | shellExec('sudo bastille list all') |
src/startup-report.ts:801 |
| Package queries | shellExec('sudo pkg version ...') |
src/startup-report.ts:803, src/telegram-commands.ts:1264, src/channels/telegram.ts:255 |
| ZFS snapshots | execFileSync('sudo', ['zfs', 'snapshot', ...]) |
setup/install.ts:305 |
| PostgreSQL user switch | execFileSync('sudo', ['-n', '-u', 'postgres', ...]) |
setup/skills-memory.ts:233 |
| Service reload | sudo service nginx reload |
scripts/docs-sync.cron.sh:272 |
| Jail destroy | sudo bastille stop/destroy |
scripts/destroy-jails.sh:47,59 |
| Heartbeat | sudo timeout 10 bastille list |
scripts/heartbeat.sh:48 |
2.2 Setup / Install Time
| File | Usage |
|---|---|
setup/environment.ts |
Checks commandExists('sudo'), constructs sudo pkg install commands |
setup/install.ts |
useSudo flag, sudo zfs snapshot for non-root installs |
setup.sh |
Checks command -v sudo, sets missing_no_sudo status |
setup/bastille-helpers.ts |
Creates /usr/local/etc/sudoers.d/<user>-bastille NOPASSWD entry |
setup/service.ts |
Reads SUDO_USER/SUDO_UID/SUDO_GID env vars |
setup/hostd.ts |
Reads SUDO_USER/SUDO_UID/SUDO_GID for file ownership |
setup/profile.ts |
Reads SUDO_UID/SUDO_GID for file ownership |
setup/verify-agent-jails.ts |
Verifies sudoers file exists |
2.3 Sudoers Configuration
setup/bastille-helpers.ts:156-181— Creates/usr/local/etc/sudoers.d/<agentUser>-bastillewith NOPASSWD entry for bastille, validates withvisudo -cfsetup/verify-agent-jails.ts:211-214— Checks sudoers drop-in exists- AGENTS.md documents the sudoers policy (read
/usr/local/etc/sudoers, confirm@includedir, validate withvisudo -c)
2.4 Documentation / Instruction References
~40+ files across skills, public docs, internal docs, CMS content, and HTML pages reference sudo in operator-facing instructions. Major concentrations:
- Skill files: debug, nginx, strapi, zfs-scrub, setup, freebsd-admin, postgres-memory, docs-deployment, update, agent-setup, docs-localization-pipeline
- Public docs: install guide, fresh-install checklist, controlplane install, security, db disaster recovery
- Internal docs: STRAPI-FREEBSD-SETUP, LOCAL-LLM, POSTGRES-MEMORY, CLEAN-RESET-PI-TUI, AGENT-HARNESS-V2
- Bootstrap CMS: ~15+ content markdown files in English and Slovenian
- HTML: docs site, changelog, landing page guides
2.5 Safety Harness
.agent/harness/safety.yaml defines a confirm-sudo rule requiring confirmation for sudo commands — this would need rewriting for mdo.
3. Critical Architectural Context: hostd
The codebase already has a root-privileged daemon (hostd) at src/hostd/. It runs as root and the non-root agent communicates via Unix socket. The CHANGELOG explicitly states:
"Agent user calls
hostd(op, params)fromsrc/hostd/client.ts; never needs sudo at runtime."
hostd already calls bastille, zfs, zpool, pfctl, service, pkg, sysrc, sanoid, shutdown directly via spawnSync — no sudo needed.
The only significant runtime sudo holdout is jail-exec-runner.ts which still does spawnSync('sudo', ['bastille', 'cmd', ...]) for agent jail execution.
4. Comparative Analysis
4.1 Where mac_do is Stronger Than sudo
| Aspect | sudo | mac_do |
|---|---|---|
| Trust model | Trusts setuid binary + sudoers parser | Kernel-enforced, no setuid binary to compromise |
| Credential change | Piecewise (setuid → setgid → setgroups) | Atomic via setcred(2) |
| Config location | /usr/local/etc/sudoers (file) |
sysctl / jail param (kernel state) |
| Password dependency | Optional but infrastructure exists | None — purely role-based |
| External dependency | Ports package (security/sudo) |
Base system |
| Per-jail scoping | Not native | mac.do.rules jail parameter |
4.2 Where mac_do Falls Short
| Concern | Severity | Detail |
|---|---|---|
| No audit logging | HIGH | sudo logs every invocation to /var/log/auth.log (timestamp, user, command, exit code). mac_do has zero logging. An agent system that executes privileged commands autonomously needs traceability. |
| No per-command granularity | HIGH | mac_do rules allow a UID/GID transition, not a specific command. uid=1001>uid=0 lets the agent become root for anything. sudoers lets you restrict: agent ALL=(root) NOPASSWD: /usr/local/bin/bastille. |
| Loss of sudo env vars | MEDIUM | The codebase reads SUDO_USER, SUDO_UID, SUDO_GID in setup/service.ts, setup/hostd.ts, setup/profile.ts, setup/install.ts. mdo does not set these. |
| FreeBSD 14.x incomplete | MEDIUM | Basic mdo exists in 14.2 but group control (-g, -G, -s, fine-grained UID/GID) is 15.0+ only. ISO currently targets 14.x. |
| Kernel module requirement | LOW | Must load mac_do at boot via loader.conf. Adds a system requirement. |
| Hardcoded path | LOW | Only /usr/bin/mdo works currently. Not configurable. |
| Bastille ecosystem | LOW | Bastille documentation expects sudo. Every skill and doc in this repo says sudo. |
No sudo -u equivalent clarity |
LOW | sudo -u postgres psql is a well-understood idiom. mdo -u postgres psql works but the transition rules must be set up for both directions (agent→root AND root→postgres). |
5. Recommendations
5.1 Do Not Replace sudo With mac_do Now
The security gain is marginal and the trade-offs are net negative:
- You lose per-command restriction (sudoers: "only bastille" vs. mac_do: "become root")
- You lose audit logging in an autonomous agent context
- You lose sudo env vars that setup code depends on
- 14.x support is incomplete
- Massive documentation churn (~100+ files) for no operator-visible benefit
5.2 Do Finish the hostd Migration
The correct path is to eliminate the need for runtime sudo entirely:
- Move
jail-exec-runner.ts'ssudo bastille cmdto hostd — hostd already runs as root with a Unix socket API. This is the last significant runtime sudo use. - Move
sudo pkg versionto hostd — startup report and Telegram commands already have hostd access. - Move shell script sudo calls to hostd —
heartbeat.sh,destroy-jails.sh,docs-sync.cron.shcan call hostd client instead.
Result: zero runtime sudo, no kernel module needed, no doc churn, no lost audit trail.
5.3 Where mac_do Could Be Useful Later
mac_do becomes interesting for a different problem than "replace host sudo":
- Per-jail privilege isolation for multi-tenant: each jail gets its own
mac.do.rulesallowing restricted credential transitions inside the jail without sudo installed in the jail at all - Removing sudo from the base install entirely: if hostd absorbs all privileged operations, you could uninstall sudo and use mac_do as a minimal fallback for interactive operator access
- Defense in depth: even with hostd, mac_do could enforce that only specific UID transitions are possible at the kernel level, limiting blast radius of any hostd vulnerability
These are future considerations, not current priorities.
6. References
mac_do(4): https://man.freebsd.org/cgi/man.cgi?query=mac_domdo(1): https://man.freebsd.org/cgi/man.cgi?query=mdosetcred(2): https://man.freebsd.org/cgi/man.cgi?query=setcredmac(4): https://man.freebsd.org/cgi/man.cgi?query=mac- Commit introducing mdo: FreeBSD 14.2 (basic), FreeBSD 15.0 (full group/fine-grained control)
- Authors: Olivier Certner
<olce@FreeBSD.org>, Baptiste Daroussin<bapt@FreeBSD.org>