This page is SECURITY.md in the repository. The full policy is docs/05.
Security
Aromba starts with almost no rights, earns them over a week, and loses them the moment it behaves strangely. The AI proposes; a separate guard executes; you tap.
The full policy is docs/05-security.md. This file says what the code does today, in plain words, and marks what is still to come; it grows with each release, and every security fix is published in the changelog with its own line. Disclosure: security@aro.mba — say what you found and how; a fix ships before the report is public, and the report is credited.
Two processes that do not trust each other
Aromba is two programs. The Worker runs as the unprivileged user aromba: it keeps the schedule, talks to the chat, reads the health numbers and runs the assistant. It has no sudo, no privileges of its own, and its own code never executes a program (CI fails if any package of ours in the Worker binary imports os/exec). Everything that touches the system is a request to the Warden over a local socket. The Warden runs as root, is small, links no HTTP client, no database and no config parser, and does five things with a request: checks it against the policy, checks the approval a red action needs, runs it inside the cage, redacts the result, and writes one journal line. If the assistant, an injected instruction or a bug fully controls the Worker, it controls a process that can only ask.
aromba-warden runs as root and is the only process that executes anything. It serves two Unix sockets and asks the kernel who is on the other end of every connection (SO_PEERCRED): the Worker’s socket answers only the aromba user, the control socket only root — so aromba panic, aromba resume and their kin work from a root terminal and from nowhere else. It never runs a shell: a request is a bare command name plus arguments, matched against a compiled-in list of read-only commands, each with its own grammar — and, for the chores we ship and nobody else, against a second, shorter list of three commands the cage cannot hold (systemctl restart of one service, timedatectl set-ntp true, apt-get install of named packages that are installed here or on a list of one), run after the chore has ended cleanly, each a journal line and a receipt line; the run gets a fixed binary path, an empty environment, / as the working directory, a timeout and a byte cap on output. Paths must be absolute and free of .., are resolved through symlinks, and the result is checked against the secret and fence rules. /etc/aromba/warden/policy.toml is the Warden’s own rendering of that policy, shown here and on the dashboard verbatim; the Warden never reads it back, and editing it trips the integrity check.
Integrity: SHA-256 of both binaries and the policy rendering, taken at install, checked at start and every hour. A mismatch moves the ladder to step 3 (reads only; from phase 3, the AI key wiped too), never a warning. The Warden links the standard library and the signature package and nothing else — no HTTP, no database, no config parser — and CI fails on a new import.
The journal is the Warden’s: O_APPEND, fsync on every line, rotated by the Warden alone; the Worker can add lines through the Warden and can only read.
The cage: a chore may write only where its header says
Every chore runs in a namespace (bubblewrap) where only the paths its header lists under touches are writable; everything else is read-only, and the paths that hold secrets — key files, .env, password stores, the Warden’s own directory — are not mounted at all. A chore that declares “Docker cache” and writes to a home directory gets a permission error, is killed, and the event is journaled. The cage also loads a seccomp filter that refuses Unix sockets outright, so no chore can reach a daemon’s socket even if one were left visible, and a skill’s chores have no network at all unless its manifest asked for it and the PIN was typed. The rule applies to every chore, ours included, so our own mistakes are caught the same way. A skill declares its paths, root commands and hosts in a manifest, shown before install; nothing widens after install.
Some hosts — LXC guests, some container VPSes — cannot create namespaces. There the Warden runs only the green chores of the system skill and refuses every yellow, red, skill and mine/ chore, and says so: “This server has no isolation. I only run green system chores here.” There is no setting and no button to run uncaged; the Trust page shows isolation: none.
Damage cap: no chore may change more than 1,000 files or 20 GB in one run unless its header declares a higher max_changes and the user confirmed it. The Warden checks the cap every 5 seconds during the run and kills at it; in those 5 seconds a chore can exceed it. The exact count is taken after the run and journaled.
An assistant tool a skill brought is the narrowest case: it runs in a cage with nothing writable at all, not even the skill’s own paths, because it runs on the model’s word rather than on a button. A tool that needs to write is a chore, which has an approval, a snapshot and a receipt.
The ladder: what happens when Aromba itself behaves strangely
The Warden watches Aromba, not the server. Eight signals — too many commands in ten minutes, deny-list hits, reads of secret or fenced paths, an outbound connection to a host network.toml does not list, files changed outside a chore’s touches, a changed binary or policy, a red action without a fresh approval, any read of a honeypot file — move it up a ladder of four steps, and each step tells the user in the chat:
- Slow — one command a minute. “Unusual activity, I’ve slowed down. Was that you?”
- Freeze — schedule and assistant stopped, chat alive.
- Cut — reads only; from phase 3, when there is an AI key, it is wiped from memory and disk. Back only with
aromba resumein a root terminal. - Disarm — the Warden stops itself. What is left is a Worker with no rights. Reinstall to recover.
Honeypot reads, integrity failures and unknown hosts jump straight to step 3; everything else climbs one step per trigger and climbs down after 24 quiet hours. /panic in the chat is step 3; aromba panic on the server is step 4. False positives cost the user one tap; true positives cost the attacker the server.
The integrity check covers both binaries, the policy rendering, the fence list and the Warden’s own state records — at start and every hour; D34 says why the ladder’s own file is not in it — and a change moves the ladder to step 3. /panic and aromba panic work as described, and aromba resume brings it back.
Safe by default, without understanding
Prod mode turns on by itself when the first scan sees a production server — a public domain answering HTTP, a database with data, sustained traffic, or an app older than 30 days: every chore is then red, nothing is deleted (quarantine never purges), the assistant only proposes; turning it off is a PIN action that shows the consequences first. For the first 7 days after install only green chores can run, whatever the user enables. Every chore’s first real run is preceded by a dry run the user saw. Any red action that changes files snapshots them first, and the receipt line has “Undo” for 7 days. Folders that look like data — database directories, uploads, backups, mail, repositories with uncommitted work — are fenced automatically at the first scan and weekly, and no chore may touch them.
Updates
A release is two tarballs — the two binaries, nothing else — and one manifest naming their hashes, signed with the release key; the public keys are compiled into both binaries from keys/, and a build without them refuses to update and says so. The installer verifies the manifest with minisign and the tarball’s hash before anything moves. The daemon’s daily check downloads the same files into its own folder and asks the Warden, which is the only thing that writes a binary: it verifies the signature and the hash again, unpacks into a folder of its own under /opt/aromba/releases/, swaps one link, keeps the previous release, rewrites its integrity baseline and restarts. A tarball with anything but the two binaries in it — another file, a directory, a link, a path with a separator — is refused whole. Patch releases install themselves in the quiet window; minor and major ones ask in the chat and install on a tap. If the new release fails to start three times, systemd runs the previous release’s Warden to put it back, the chat is told once, and self-updates pause until the release after the failed one. Updates are refused while the ladder is at Freeze or above. aromba uninstall and aromba update work from a root terminal; nothing in the chat can trigger an uninstall.
The relay
The relay at aro.mba is the only place the Telegram bot token exists: it comes from the environment at start, is dropped from the environment right after, and every error from the Telegram client is scrubbed of it before it can be logged. Telegram’s webhook is accepted only with the shared secret header; without it the path is a 404. A server registers with an id and a token it generated itself; the relay stores the token hashed, limits registrations per address, erases unpaired servers after 24 hours, and never overwrites a record for a known id with a different boot id — that goes to the duplicate check and the chat decides. Pairing links are six characters, single use, fifteen minutes, bound to one server; one server per Telegram account; a message from any other chat gets one reply and then silence. Message bodies are deleted from the relay on delivery and after 24 hours at most; what the relay keeps for good is a count of installs and pairings per day and install source, with no server, chat or address in it; and the relay’s log carries ids and counts, never text — a test reads the log and looks for the messages it sent. A button tap is forwarded only when it names a message the relay delivered to that chat and a button that message had; any other callback data is answered “expired” and dropped, so the relay cannot make a server do anything a server did not first offer. The daemon’s connection is outbound only, authenticated with the relay token, protected from replays by a nonce and a timestamp on every frame, and reconnects forever; the daemon opens no other connection than to the hosts in network.toml. When the relay’s name fails to resolve, the daemon dials the addresses network.toml lists for it, but the certificate is still verified against the relay’s name — an address alone is never trusted, and verification is never switched off.
Redaction
Every result the Warden returns passes through the redactor before it can reach a model, a log or Telegram; the count of removals is journaled. It removes private-key blocks whole (PEM, OpenSSH, PGP, including a block cut off mid-way), provider and platform tokens (OpenAI, Anthropic, OpenRouter, Stripe, GitHub, AWS, Slack, Google, Telegram bot tokens — also inside Bot API URLs), JWTs, passwords in connection strings (postgres://, redis://, mongodb+srv://, and any other scheme) and in Basic/Bearer headers, and any assignment (NAME=value, NAME: value, "NAME": "value") where the name contains key, token, secret, password, passwd, pwd, credential, bearer or auth and the value is 12+ characters and not a path.
Known limit: a passphrase with no digits that follows the name with only whitespace (password correcthorsebatterystaple) is not redacted, because that shape is prose in most logs (“password authentication failed”).
What review has been done
Twenty-seven rounds of adversarial review against this design and the Warden’s code, each round’s findings fixed before the next — releases 0.1.2 to 0.1.9 are named for them, and the last came back with no High or Medium finding.
An external review by somebody who breaks things for a living has not been done. It is on the list of what happens after launch, not before it. We would rather say that here than let the rounds above read as one. If you break something, security@aro.mba, and every security fix is published in the changelog.
The policy, verbatim
What the Worker may ask for without a button: the Warden’s own rendering of its compiled-in list, the same file it writes to /etc/aromba/warden/policy.toml at install. TestSecurityMDCarriesThePolicyVerbatim keeps this block equal to the running code.
# Aromba Warden policy — what the Worker may ask for without a button.
# Rendered by aromba-warden from its compiled-in policy. Editing this file
# changes nothing and trips the integrity check. Everything not listed is refused.
[limits]
timeout_s = 30
max_output_bytes = 1048576
[[allow]]
cmd = "df"
bins = ["/usr/bin/df", "/bin/df"]
doc = "disk usage by filesystem, space and inodes"
flag_letters = "PhklTai"
tokens = ["--output=source,fstype,size,used,avail,pcent,target", "--output=target,pcent,avail,size", "-B1", "--block-size=1", "--total"]
valued = ["-t <fstype>", "-x <fstype>"]
positional = "path"
max_args = 12
timeout_s = 30
[[allow]]
cmd = "du"
bins = ["/usr/bin/du", "/bin/du"]
doc = "size of directories"
flag_letters = "sxkmhca"
tokens = ["-B1", "--block-size=1", "--max-depth=1", "--max-depth=2", "--max-depth=3", "--apparent-size", "--total"]
valued = ["-d <number>"]
positional = "path"
max_args = 40
timeout_s = 300
low_priority = true # nice -n 19, ionice -c 3
[[allow]]
cmd = "ls"
bins = ["/usr/bin/ls", "/bin/ls"]
doc = "list a directory"
flag_letters = "laAhtrS1dp"
tokens = ["--time-style=long-iso", "--time-style=iso", "--time-style=full-iso"]
positional = "path"
max_args = 12
timeout_s = 30
[[allow]]
cmd = "ps"
bins = ["/usr/bin/ps", "/bin/ps"]
doc = "process list"
tokens = ["aux", "axo", "-eo", "-e", "-o", "-ww", "--no-headers", "--sort=-%mem", "--sort=-%cpu", "--sort=-rss", "pid,ppid,user,%cpu,%mem,rss,vsz,etime,stat,comm,args", "pid,user,%cpu,%mem,rss,etime,comm", "pid,ppid,user,etime,comm,args", "pid,comm,args"]
positional = "none"
max_args = 6
timeout_s = 30
[[allow]]
cmd = "docker"
bins = ["/usr/bin/docker", "/usr/local/bin/docker"]
doc = "containers, images, disk use and a container's own log — read only"
first = ["ps", "images", "system", "version", "info", "stats", "logs"]
tokens = ["-a", "--all", "--no-trunc", "-q", "df", "-v", "--no-stream", "--format", "--filter", "-t", "--timestamps"]
valued = ["--filter <filter>", "--format <template>", "--since <since>", "--tail <number>", "--until <since>", "-n <number>"]
prefixed = ["--since=<since>", "--tail=<number>", "--until=<since>"]
positional = "container"
max_args = 10
timeout_s = 120
[[allow]]
cmd = "systemctl"
bins = ["/usr/bin/systemctl", "/bin/systemctl"]
doc = "service state — read only"
first = ["status", "list-units", "list-timers", "list-unit-files", "is-active", "is-enabled", "is-failed", "--failed"]
tokens = ["--failed", "--all", "-a", "--no-pager", "--plain", "--no-legend", "-l", "--full", "--type=service", "--type=timer", "--state=failed", "--state=running", "--state=active", "--quiet", "-q"]
valued = ["--lines <number>", "-n <number>"]
prefixed = ["--lines=<number>"]
positional = "unit"
max_args = 12
timeout_s = 30
[[allow]]
cmd = "journalctl"
bins = ["/usr/bin/journalctl", "/bin/journalctl"]
doc = "logs — read only, bounded"
tokens = ["--no-pager", "-q", "--quiet", "-r", "--reverse", "-b", "--utc", "-k", "--dmesg", "-x", "--disk-usage", "--list-boots"]
valued = ["--grep <pattern>", "--lines <number>", "--output <output>", "--priority <priority>", "--since <since>", "--unit <unit>", "--until <since>", "-S <since>", "-U <since>", "-g <pattern>", "-n <number>", "-o <output>", "-p <priority>", "-u <unit>"]
prefixed = ["--grep=<pattern>", "--lines=<number>", "--output=<output>", "--priority=<priority>", "--since=<since>", "--unit=<unit>", "--until=<since>"]
positional = "match"
max_args = 16
timeout_s = 120
[[allow]]
cmd = "apt"
bins = ["/usr/bin/apt"]
doc = "pending updates — read only"
first = ["list"]
tokens = ["--upgradable", "--upgradeable", "--installed", "-a"]
positional = "none"
max_args = 4
timeout_s = 120
[[allow]]
cmd = "ss"
bins = ["/usr/bin/ss", "/bin/ss"]
doc = "listening sockets"
flag_letters = "tulnpasH46"
tokens = ["state", "listening", "established"]
positional = "none"
max_args = 8
timeout_s = 30
[[allow]]
cmd = "free"
bins = ["/usr/bin/free", "/bin/free"]
doc = "memory and swap"
flag_letters = "mhbkgwt"
positional = "none"
max_args = 4
timeout_s = 30
[[allow]]
cmd = "uptime"
bins = ["/usr/bin/uptime", "/bin/uptime"]
doc = "uptime and load"
flag_letters = "ps"
positional = "none"
max_args = 2
timeout_s = 30
[[allow]]
cmd = "hostname"
bins = ["/usr/bin/hostname", "/bin/hostname"]
doc = "host name"
flag_letters = "fsI"
positional = "none"
max_args = 2
timeout_s = 30
[[allow]]
cmd = "nproc"
bins = ["/usr/bin/nproc"]
doc = "cpu count"
positional = "none"
max_args = 0
timeout_s = 30
[[allow]]
cmd = "cat"
bins = ["/usr/bin/cat", "/bin/cat"]
doc = "read one of the listed system files"
positional = "file"
max_args = 4
timeout_s = 30
[[allow]]
cmd = "fail2ban-client"
bins = ["/usr/bin/fail2ban-client"]
doc = "jail status"
first = ["status", "ping", "version"]
positional = "jail"
max_args = 3
timeout_s = 30
[[allow]]
cmd = "timedatectl"
bins = ["/usr/bin/timedatectl", "/bin/timedatectl"]
doc = "clock and time sync state — read only"
first = ["show", "status", "timesync-status"]
tokens = ["--no-pager"]
positional = "none"
max_args = 2
timeout_s = 30
[[allow]]
cmd = "ufw"
bins = ["/usr/sbin/ufw", "/usr/bin/ufw"]
doc = "firewall state — read only"
first = ["status"]
tokens = ["verbose", "numbered"]
positional = "none"
max_args = 2
timeout_s = 30
[[allow]]
cmd = "nginx"
bins = ["/usr/sbin/nginx", "/usr/bin/nginx"]
doc = "test the configuration — read only"
flag_letters = "tqv"
positional = "none"
max_args = 2
timeout_s = 30
[[allow]]
cmd = "nft"
bins = ["/usr/sbin/nft", "/usr/bin/nft"]
doc = "the firewall's ruleset — read only"
first = ["list"]
tokens = ["ruleset", "tables", "chains", "-a", "-n"]
positional = "none"
max_args = 4
timeout_s = 30
[cat]
files = ["/etc/os-release", "/usr/lib/os-release", "/etc/debian_version", "/etc/lsb-release", "/etc/hostname", "/etc/timezone", "/etc/fstab", "/var/run/reboot-required", "/run/reboot-required", "/var/run/reboot-required.pkgs", "/run/reboot-required.pkgs", "/proc/loadavg", "/proc/meminfo", "/proc/uptime", "/proc/cpuinfo", "/proc/version", "/proc/stat", "/proc/swaps", "/proc/mounts", "/proc/diskstats", "/proc/vmstat", "/proc/net/dev", "/proc/sys/kernel/random/boot_id", "/proc/sys/kernel/hostname"]
# What a chore may have the Warden run for it after a clean real run,
# outside the cage: a red chore any of these, a green one only time sync's
# two (timedatectl set-ntp true; restart of a time-sync unit). Nothing else.
[[chore_action]]
cmd = "systemctl"
bins = ["/usr/bin/systemctl", "/bin/systemctl"]
doc = "restart one service"
first = ["restart"]
positional = "service"
min_args = 2
max_args = 2
timeout_s = 120
[[chore_action]]
cmd = "timedatectl"
bins = ["/usr/bin/timedatectl", "/bin/timedatectl"]
doc = "turn time sync on"
first = ["set-ntp"]
tokens = ["true"]
positional = "none"
min_args = 2
max_args = 2
timeout_s = 30
[[chore_action]]
cmd = "docker"
bins = ["/usr/bin/docker"]
doc = "remove dangling images, or the build cache not in use"
first = ["image", "builder"]
tokens = ["prune", "-f", "--force"]
positional = "none"
min_args = 3
max_args = 3
timeout_s = 900
low_priority = true
[[chore_action]]
cmd = "apt-get"
bins = ["/usr/bin/apt-get"]
doc = "upgrade named installed packages, or install one of: fail2ban"
first = ["install"]
tokens = ["-y", "--only-upgrade"]
positional = "package"
min_args = 3
max_args = 24
warden_adds = ["-qq", "--no-remove", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold", "-o", "DPkg::Lock::Timeout=120"]
warden_env = ["DEBIAN_FRONTEND=noninteractive"]
timeout_s = 900
low_priority = true
[chore_action_rules]
# Core chores only. A restart never names a service matching this, whatever the chore's color:
never_restart = "^(?:aromba.*|systemd-(?:reboot|poweroff|halt|kexec|suspend|hibernate|hybrid-sleep|suspend-then-hibernate|soft-reboot|exit)|emergency|rescue|dbus|dbus-broker|systemd-logind|systemd-journald|systemd-udevd)(?:@.*)?\\.service$"
new_packages = ["fail2ban"]
# A package is one dpkg lists as installed here, or one of new_packages; its name ends in a letter, a digit or +.
package_name = "^[a-z0-9][a-z0-9+.-]{0,126}[a-z0-9+]$"
[paths]
# Absolute, no "..", resolved through symlinks, then checked against these.
secret_prefixes = ["/etc/aromba/worker/secrets.toml", "/etc/aromba/worker/relay.toml", "/etc/aromba/worker/calendars.toml", "/etc/aromba/worker/intake.toml", "/etc/aromba/warden", "/etc/sudoers", "/etc/sudoers.d", "/etc/shadow", "/etc/gshadow", "/etc/ssh/ssh_host_", "/root/.ssh", "/proc/self", "/proc/thread-self", "/dev", "/sys/firmware"]
secret_dirs = [".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".password-store", ".config/gcloud", ".config/gh"]
secret_names = [".env*", "*.pem", "*.key", "*_key", "id_*", "*secret*", "*token*", "*credential*", "*password*", ".netrc", "authorized_keys", "secrets.toml", "*.p12", "*.pfx", "*.jks", "*.keystore"]
fences = []
[deny]
# Matched against the whole request; a hit counts toward the ladder.
rm-root = "\\brm\\b(?:\\s+(?:-\\S+|--\\S+))*\\s+-\\S*[rR]\\S*(?:\\s+(?:-\\S+|--\\S+))*\\s+(?:/|/\\*|~|~/|\\$HOME|\\$HOME/|/(?:etc|usr|var|boot|bin|sbin|lib|lib64|home|root|opt|srv)/?)(?:\\s|$)"
dd-device = "\\bdd\\b.*\\bof=/dev/"
mkfs = "(?:^|[;&|]\\s*|\\bsudo\\s+)(?:mkfs(?:\\.\\w+)?|wipefs|mkswap)\\b"
pipe-to-shell = "\\b(?:curl|wget)\\b.*\\|\\s*(?:sudo\\s+)?(?:sh|bash|zsh|dash|ksh)\\b"
protected-path = "/etc/sudoers|/etc/aromba/|/etc/ssh/sshd_config|authorized_keys|/etc/g?shadow\\b|\\bvisudo\\b"
chmod-777 = "\\bchmod\\b(?:.*\\s-R\\b.*\\s[ugoa]*0?777\\b|.*\\s0?777\\b.*\\s-R\\b)"
power = "(?:^|[;&|]\\s*|\\bsudo\\s+)(?:shutdown|reboot|halt|poweroff)\\b|\\bsystemctl\\s+(?:reboot|poweroff|halt|kexec|suspend|hibernate)\\b|(?:^|[;&|]\\s*|\\bsudo\\s+)(?:tel)?init\\s+[06]\\b"
firewall-flush = "\\biptables\\b.*\\s(?:-F|--flush)\\b|\\bnft\\s+flush\\s+ruleset\\b|\\bufw\\s+(?:disable|reset)\\b"
history = "\\bhistory\\s+-c\\b"
secret-path = "(?:^|[\\s/=:'\"])\\.(?:ssh|aws|gnupg|kube|docker|azure|password-store)(?:/|\\s|$)|(?:^|[\\s/=:'\"])\\.env(?:\\.[\\w-]+)?\\b|\\.(?:pem|key|p12|pfx)\\b|\\bid_(?:rsa|dsa|ecdsa|ed25519)\\b|\\.netrc\\b|secrets\\.toml|\\b\\w*(?:secret|credential)s?\\.(?:json|ya?ml|toml|txt|env)\\b|/proc/\\d+/(?:environ|cmdline|mem)\\b"