Security Policy Engine
Aegis's file-based capability policy engine — /etc/aegis/caps.d/, two-tier grants, policy loading and lookup at exec time
Security Policy Engine
The Aegis security policy engine maps binary names to capability grants through configuration files in /etc/aegis/caps.d/. Each file defines which capabilities a binary receives at exec time, and whether those capabilities require an authenticated session.
This is the mechanism through which system administrators control per-binary privilege. Without a policy file, a binary receives only the baseline capabilities. With a policy file, it can receive additional capabilities at either the service tier (unconditional) or the admin tier (requires authentication).
Maturity warning. Aegis v1 is the first publicly released version, not a production-hardened system. The policy engine described here implements the intended security model, but the kernel as a whole – predominantly written in C – has not undergone the depth of adversarial review that would justify strong assurance claims. Undiscovered vulnerabilities almost certainly exist, as is expected for any from-scratch OS at this stage. The policy engine itself is implemented in C; the capability validation core is in Rust, and future work will expand the Rust boundary to cover more of the security-critical path. Contributions are welcome – file issues or propose changes at exec/aegis.
Architecture Overview
Boot sequence:
kernel_main()
|-- vfs_init() + ext2_mount() // filesystem must be ready
|-- cap_policy_load() // parse /etc/aegis/caps.d/*
+-- proc_spawn("/bin/init") // first process
Runtime (every exec/spawn):
sys_exec(path) / sys_spawn(path)
|-- zero capability table
|-- grant baseline caps (6)
|-- cap_policy_lookup(path) // extract basename, search table
|-- if policy found:
| grant service-tier caps unconditionally
| grant admin-tier caps if proc->authenticated == 1
+-- apply cap_mask if provided (spawn only)
The policy engine is implemented in kernel/cap/cap_policy.c (approximately 240 lines of C). It uses no heap allocation – all state lives in a static array sized at compile time. Note that while the capability validation functions (cap_check, cap_grant) are implemented in Rust, the policy engine itself – parsing, loading, and lookup – remains in C. This is a candidate for future migration to Rust as part of the kernel’s gradual C-to-Rust transition.
Policy File Format
Each file in /etc/aegis/caps.d/ is named after the binary’s basename (e.g., httpd, login, stsh). The file contains one or more lines, each specifying a tier and one or more capability names:
tier CAP_NAME [CAP_NAME ...]
Where:
- tier is either
serviceoradmin - CAP_NAME is one of the recognized capability kind names (see table below)
- Lines starting with
#are comments - Blank lines are ignored
- Multiple capability names can appear on the same line, separated by spaces
Example Policy Files
/etc/aegis/caps.d/login – The login binary needs AUTH (to read /etc/shadow) and SETUID (to change user identity after authentication):
service AUTH SETUID
/etc/aegis/caps.d/httpd – A web server needs only network socket access:
service NET_SOCKET
/etc/aegis/caps.d/stsh – The Aegis shell grants administrative capabilities only to authenticated sessions:
admin DISK_ADMIN POWER CAP_DELEGATE CAP_QUERY
admin PROC_READ
/etc/aegis/caps.d/dhcp – The DHCP client needs socket access and network configuration:
service NET_SOCKET NET_ADMIN
/etc/aegis/caps.d/lumen – The Lumen compositor needs framebuffer access, threading, process management, and power control:
service FB THREAD_CREATE PROC_READ POWER
/etc/aegis/caps.d/vigil – The init system needs power control (for shutdown/reboot orchestration):
service POWER
/etc/aegis/caps.d/installer – The text installer needs disk, auth, and identity access at admin tier (so it can chown the new user’s home directory):
admin DISK_ADMIN AUTH SETUID
/etc/aegis/caps.d/bastion – The session manager needs auth, framebuffer, and identity capabilities at service tier:
service AUTH FB SETUID
Recognized Capability Names
The policy parser maps these string names to CAP_KIND_* constants:
| Policy Name | Constant | Value |
|---|---|---|
VFS_OPEN |
CAP_KIND_VFS_OPEN |
1 |
VFS_WRITE |
CAP_KIND_VFS_WRITE |
2 |
VFS_READ |
CAP_KIND_VFS_READ |
3 |
AUTH |
CAP_KIND_AUTH |
4 |
CAP_GRANT |
CAP_KIND_CAP_GRANT |
5 |
SETUID |
CAP_KIND_SETUID |
6 |
NET_SOCKET |
CAP_KIND_NET_SOCKET |
7 |
NET_ADMIN |
CAP_KIND_NET_ADMIN |
8 |
THREAD_CREATE |
CAP_KIND_THREAD_CREATE |
9 |
PROC_READ |
CAP_KIND_PROC_READ |
10 |
DISK_ADMIN |
CAP_KIND_DISK_ADMIN |
11 |
FB |
CAP_KIND_FB |
12 |
CAP_DELEGATE |
CAP_KIND_CAP_DELEGATE |
13 |
CAP_QUERY |
CAP_KIND_CAP_QUERY |
14 |
IPC |
CAP_KIND_IPC |
15 |
POWER |
CAP_KIND_POWER |
16 |
INSTALL |
CAP_KIND_INSTALL |
17 |
Unrecognized capability names produce a warning via printk and are skipped:
[CAP_POLICY] WARN: unknown cap 'BOGUS_CAP'
The Two-Tier Model
The policy engine supports two tiers of capability grants, controlled by the tier keyword in the policy file:
Service Tier (CAP_TIER_SERVICE = 1)
Service-tier capabilities are granted unconditionally at exec time. Any process that execs a binary with a service-tier policy gets those capabilities, regardless of whether the session is authenticated.
Use service-tier for capabilities that the binary fundamentally needs to function:
- A web server needs
NET_SOCKETto open sockets - The login program needs
AUTHto read/etc/shadow - The compositor needs
FBto access the framebuffer
Admin Tier (CAP_TIER_ADMIN = 2)
Admin-tier capabilities are granted only if proc->authenticated == 1. The authenticated flag is set by sys_auth_session (syscall 364), which itself requires CAP_KIND_AUTH.
Use admin-tier for capabilities that represent elevated privilege and should only be available to logged-in users:
- The shell gets
DISK_ADMINandPOWERonly after login - The installer gets
DISK_ADMINandAUTHonly in authenticated sessions
Authentication Flow
Vigil (PID 1)
|
+-- spawns login
[baseline + AUTH + SETUID] (service tier)
|
login verifies password
|
sys_auth_session(uid, gid) (requires AUTH cap)
proc->authenticated = 1
proc->auth_uid = uid, proc->auth_gid = gid
|
sys_exec("/bin/stsh")
|
+-- exec boundary: reset + baseline + policy lookup
stsh policy has "admin DISK_ADMIN POWER ..."
proc->authenticated == 1, so admin caps granted
|
stsh running with admin capabilities
|
+-- fork + exec "/usr/bin/httpd"
|
exec boundary: reset + baseline + policy
httpd policy: "service NET_SOCKET"
(service tier, granted unconditionally)
proc->authenticated == 1 (inherited, but
httpd has no admin-tier caps in its policy)
The authenticated flag is inherited across fork, clone, and exec. This means the entire session tree rooted at the login process shares the authenticated state. However, having the flag alone is not sufficient – the binary must also have admin-tier entries in its policy file to receive admin capabilities.
Internal Data Structures
Policy Entry
Each loaded policy is stored as a cap_policy_entry_t:
/* kernel/cap/cap.h */
/* One capability in a policy entry */
typedef struct {
uint32_t kind; /* CAP_KIND_* */
uint32_t rights; /* CAP_RIGHTS_* */
uint32_t tier; /* CAP_TIER_SERVICE or CAP_TIER_ADMIN */
} cap_policy_cap_t;
/* A single policy entry -- maps a binary basename to its caps */
typedef struct {
char name[64]; /* binary basename, e.g. "httpd" */
cap_policy_cap_t caps[CAP_POLICY_MAX_CAPS];
uint32_t count; /* number of valid caps[] entries */
} cap_policy_entry_t;
Static Policy Table
The policy engine stores all loaded entries in a static array:
/* kernel/cap/cap_policy.c */
static cap_policy_entry_t s_entries[CAP_POLICY_MAX_ENTRIES];
static uint32_t s_entry_count;
Limits (defined in cap.h):
CAP_POLICY_MAX_CAPS = 16– maximum capabilities per policy entryCAP_POLICY_MAX_ENTRIES = 32– maximum total policy files
Rights Assignment
The policy parser grants all rights (READ | WRITE | EXEC = 0x7) for every capability in a policy file:
entry->caps[entry->count].rights =
CAP_RIGHTS_READ | CAP_RIGHTS_WRITE | CAP_RIGHTS_EXEC;
Fine-grained rights control is not currently exposed in the policy file format. All policy-granted capabilities receive full rights. The rights bitfield is meaningful only for baseline grants (e.g., VFS_OPEN gets READ only, VFS_WRITE gets WRITE only) and for PID 1’s PROC_READ which gets READ | WRITE.
Policy Loading
Policy files are loaded once at boot by cap_policy_load(), called from kernel_main() after the VFS and ext2 filesystem are initialized.
Load Sequence
void cap_policy_load(void)
{
s_entry_count = 0;
/* Open /etc/aegis/caps.d/ as a directory */
vfs_file_t dir;
int r = vfs_open("/etc/aegis/caps.d", 0, &dir);
if (r != 0) {
printk("[CAP_POLICY] OK: no /etc/aegis/caps.d "
"-- 0 policies loaded\n");
return;
}
/* Iterate directory entries via readdir */
while (dir.ops->readdir(...) == 0) {
/* Skip non-regular files (dtype != 8) */
/* Build path: /etc/aegis/caps.d/<name> */
/* Open and read file contents (max 512 bytes) */
/* Parse into s_entries[s_entry_count] */
/* Copy basename as entry name */
s_entry_count++;
}
printk("[CAP_POLICY] OK: %u policies loaded\n",
s_entry_count);
}
Key implementation details:
- No heap allocation. File contents are read into a 512-byte stack buffer. Policy files are expected to be small.
- No libc dependency. String comparison uses hand-written
streq()and character-by-character matching. - Graceful degradation. If
/etc/aegis/caps.d/does not exist, the engine loads zero policies and all processes run with only baseline capabilities. - Boot ordering.
cap_policy_load()must be called aftervfs_init()andext2_mount()because it reads from the filesystem.
Parse Algorithm
The parser (parse_policy) processes the file line by line:
- Skip whitespace and newlines.
- Skip lines starting with
#(comments). - Read the tier word (
serviceoradmin). Unknown tiers skip the line. - Read all remaining words on the line as capability names.
- Map each name through
cap_name_to_kind(). Unknown names produce a warning and are skipped. - Store
(kind, rights=0x7, tier)into the entry’scaps[]array. - Stop when
CAP_POLICY_MAX_CAPS(16) entries are reached.
Policy Lookup
At exec/spawn time, the kernel calls cap_policy_lookup(exe_path) to find the matching policy:
const cap_policy_entry_t *
cap_policy_lookup(const char *exe_path)
{
/* Trusted-path anchor: only grant policy caps when the binary lives
* under /bin/, /sbin/, or /apps/. Reject relative paths and "..". */
if (exe_path[0] != '/')
return NULL;
if (!starts_with(exe_path, "/bin/") &&
!starts_with(exe_path, "/sbin/") &&
!starts_with(exe_path, "/apps/"))
return NULL;
if (contains(exe_path, ".."))
return NULL;
/* Extract basename: everything after the last '/' */
const char *basename = exe_path;
const char *p = exe_path;
while (*p) {
if (*p == '/')
basename = p + 1;
p++;
}
/* Linear search through loaded policies */
for (i = 0; i < s_entry_count; i++) {
if (streq(s_entries[i].name, basename))
return &s_entries[i];
}
return NULL;
}
The trusted-path anchor runs first: only binaries under /bin/, /sbin/, or /apps/ receive policy caps. A binary at /tmp/httpd is rejected before the basename search. Paths containing .. are also rejected to block traversal such as /bin/../tmp/evil. Within the trusted tree, lookup is by basename — so /bin/httpd and /apps/httpd/httpd both match the httpd policy file.
Application at Exec
The grant logic in sys_exec (and the equivalent in sys_spawn):
/* kernel/syscall/sys_exec.c */
/* Reset capability table to baseline on exec */
for (ci = 0; ci < CAP_TABLE_SIZE; ci++) {
proc->caps[ci].kind = CAP_KIND_NULL;
proc->caps[ci].rights = 0;
}
/* Baseline: every process gets these unconditionally */
cap_grant(proc->caps, CAP_TABLE_SIZE,
CAP_KIND_VFS_OPEN, CAP_RIGHTS_READ);
cap_grant(proc->caps, CAP_TABLE_SIZE,
CAP_KIND_VFS_WRITE, CAP_RIGHTS_WRITE);
cap_grant(proc->caps, CAP_TABLE_SIZE,
CAP_KIND_VFS_READ, CAP_RIGHTS_READ);
cap_grant(proc->caps, CAP_TABLE_SIZE,
CAP_KIND_IPC, CAP_RIGHTS_READ);
cap_grant(proc->caps, CAP_TABLE_SIZE,
CAP_KIND_PROC_READ, CAP_RIGHTS_READ);
cap_grant(proc->caps, CAP_TABLE_SIZE,
CAP_KIND_THREAD_CREATE, CAP_RIGHTS_READ);
/* Policy lookup */
const cap_policy_entry_t *pol = cap_policy_lookup(path);
if (pol) {
for (ci = 0; ci < pol->count; ci++) {
if (pol->caps[ci].tier == CAP_TIER_SERVICE) {
cap_grant(proc->caps, CAP_TABLE_SIZE,
pol->caps[ci].kind,
pol->caps[ci].rights);
} else if (pol->caps[ci].tier == CAP_TIER_ADMIN
&& proc->authenticated) {
cap_grant(proc->caps, CAP_TABLE_SIZE,
pol->caps[ci].kind,
pol->caps[ci].rights);
}
}
}
Shipped Policy Configurations
Aegis ships with the following policy files in the root filesystem:
| Binary | Tier | Capabilities Granted |
|---|---|---|
vigil |
Service | POWER, INSTALL |
login |
Service | AUTH, SETUID |
bastion |
Service | AUTH, FB, SETUID |
lumen |
Service | FB, THREAD_CREATE, PROC_READ, POWER |
stsh |
Admin | DISK_ADMIN, POWER, CAP_DELEGATE, CAP_QUERY, PROC_READ |
httpd |
Service | NET_SOCKET |
dhcp |
Service | NET_SOCKET, NET_ADMIN |
chronos |
Service | NET_SOCKET |
curl |
Service | NET_SOCKET |
nettest |
Service | NET_SOCKET, NET_ADMIN |
shutdown |
Service | PROC_READ, POWER |
reboot |
Service | POWER |
hostname |
Admin | POWER |
settings |
Admin | POWER |
installer |
Admin | DISK_ADMIN, AUTH, SETUID |
gui-installer |
Admin | DISK_ADMIN, AUTH, SETUID |
herald |
Admin | INSTALL |
citadel-dock |
Service | (baseline only) |
applications |
Service | (baseline only) |
terminal |
Service | (baseline only) |
calculator |
Service | (baseline only) |
editor |
Service | (baseline only) |
filemanager |
Service | (baseline only) |
imageviewer |
Service | (baseline only) |
sysmon |
Service | (baseline only) |
sockreftest |
Service | NET_SOCKET |
Design Rationale
Several design decisions are visible in the shipped policies:
login uses service tier. AUTH and SETUID are granted unconditionally because the login binary runs before any session is authenticated. It needs AUTH to read /etc/shadow for password verification, and SETUID to change the process identity after successful login. SETUID is now constrained by the bound-identity mechanism in sys_auth_session: login can only switch to the uid/gid that was actually verified.
stsh uses admin tier. The shell’s powerful capabilities (DISK_ADMIN, POWER, CAP_DELEGATE, CAP_QUERY, PROC_READ) are only granted after the user has authenticated through login. An unauthenticated shell invocation receives only baseline capabilities.
bastion uses service tier for AUTH. The session manager (Bastion) needs AUTH at service tier because it manages the login flow itself. It also gets FB for framebuffer access (GUI login screen) and SETUID for identity switching.
Network services use service tier. httpd, dhcp, chronos, curl, and nettest receive their network capabilities unconditionally. These are system daemons or tools launched by Vigil that need networking to function, regardless of user authentication state. Every binary that calls socket() needs NET_SOCKET in its policy file — missing this causes socket() to return ENOCAP.
vigil gets INSTALL. The init system holds INSTALL so it can write to the system tree during installation setup (e.g., creating /etc/aegis structure) and for first-boot cleanup (removing live-media-only app bundles). No unprivileged process can mutate the same directories.
herald gets INSTALL at admin tier. The package manager must write binaries and policy files under /apps/ and /etc/aegis/caps.d/. Admin tier ensures package installation is only possible in an authenticated session.
installer and gui-installer get SETUID at admin tier. The installers need to chown /home/<user> to the new user’s uid after creating the home directory. They call sys_auth_session with the chosen uid/gid first, so SETUID is bound to that identity.
GUI apps with no extra caps (terminal, calculator, editor, filemanager, imageviewer, sysmon, citadel-dock, applications) hold only baseline capabilities. They communicate with the Lumen compositor and other services exclusively through AF_UNIX sockets, which is covered by the baseline IPC grant.
Writing Custom Policy Files
To grant capabilities to a new binary:
-
Create a file in
/etc/aegis/caps.d/named after the binary’s basename (no extension, no path). - Add lines specifying the tier and capability names:
# Grant unconditionally service NET_SOCKET # Grant only to authenticated sessions admin DISK_ADMIN - Reboot (policies are loaded at boot). There is currently no mechanism to hot-reload policy files.
Guidelines
-
Use service tier sparingly. Service-tier capabilities are granted to any process that execs the binary, regardless of authentication. Only use service tier for capabilities that the binary fundamentally cannot function without.
-
Prefer admin tier for dangerous capabilities.
DISK_ADMIN,POWER,CAP_DELEGATE,SETUID,AUTH, andINSTALLshould generally be admin-tier unless the binary runs as part of the boot/login sequence. -
One file per binary. The policy engine matches on basename, so each binary gets exactly one policy file. Multiple files with the same name will result in only the first one being loaded (directory iteration order dependent).
-
Keep policy files small. The parser reads at most 512 bytes per file. Policy files exceeding this size will be truncated.
-
Test with sys_cap_query. Use syscall 362 to inspect a process’s capability table after exec and verify that the expected capabilities were granted.
Security Considerations
The considerations below are known design trade-offs, not an exhaustive threat model. Aegis v1 is a from-scratch OS written predominantly in C, and at this stage of maturity the most likely real-world vulnerabilities are not policy bypass through the mechanisms described here, but memory safety bugs in the surrounding kernel code (buffer overflows, use-after-free, etc.) that could corrupt capability tables or control flow regardless of policy. The gradual C-to-Rust migration – starting with the capability validation core – is the primary strategy for reducing this class of risk over time.
Basename Matching and the Trusted-Path Anchor
Policy lookup uses the executable’s basename, but policy caps are only granted when the executable lives under a trusted system path. The cap_policy_lookup() function rejects any path that does not begin with /bin/, /sbin/, or /apps/, and additionally rejects paths containing ... A binary at /tmp/malicious/login returns NULL from cap_policy_lookup and receives only baseline capabilities.
This trusted-path anchor is enforced in kernel/cap/cap_policy.c (the starts_with / contains checks at the top of cap_policy_lookup). It closes the privilege-by-filename attack that would otherwise let any user gain AUTH or DISK_ADMIN by naming their binary after a privileged policy file.
The anchor is strengthened by the write-protected system tree: /bin, /sbin, /apps, and /etc/aegis are all install-protected at the ext2 layer. Any create/rename/chmod/unlink under them from user context requires CAP_KIND_INSTALL, checked against the inode recorded at mount (not a path-string prefix, which would be bypassable via symlinks). Only vigil and herald hold INSTALL by default. This means the trusted-for-policy paths cannot be written by unprivileged code, so the anchor is unforgeable.
Residual limitation: the path check is performed on the raw user-supplied exe_path, not a kernel-canonicalized path. The .. rejection blocks the obvious traversal (/bin/../tmp/evil), and symlinks inside /bin//sbin//apps are blocked because those directories are install-protected (creating a symlink there requires INSTALL). If a symlink were somehow introduced pointing outside the tree, it could in principle be followed; the write-protection makes this practically unreachable.
Static Policy Table
The policy table has a fixed maximum of 32 entries. If the /etc/aegis/caps.d/ directory contains more than 32 files, excess policies are silently ignored. This limit is defined by CAP_POLICY_MAX_ENTRIES in cap.h.
No Hot Reload
Policy files are read once at boot. Modifying a policy file after boot has no effect until the next reboot. This is a security feature: runtime policy modification would require careful synchronization and could introduce TOCTOU vulnerabilities.
Policy File Integrity
The policy files are stored under /etc/aegis/caps.d/ on the ext2 filesystem. Writing or replacing files in this directory requires CAP_KIND_INSTALL (enforced at the ext2 layer against the recorded inode), so an ordinary user-space process cannot modify policy files even with uid 0. Only vigil and herald hold INSTALL by default.
The broader integrity of the policy engine depends on the integrity of the filesystem image. An attacker with raw block-device access (DISK_ADMIN) could overwrite policy files at the block level, bypassing the filesystem check. Filesystem integrity protection (e.g., dm-verity, signed root images) is outside the current scope but would provide a defense-in-depth layer against this class of attack.