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 and auth access, but only for authenticated sessions:
admin DISK_ADMIN AUTH
/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 |
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() (requires AUTH cap)
proc->authenticated = 1
|
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)
{
/* 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 lookup uses the basename of the executable path. This means /bin/httpd, /usr/bin/httpd, and /opt/custom/httpd all match the same httpd policy file. This is intentional – policies are bound to binary names, not installation paths.
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 | Policy | Tier | Capabilities Granted |
|---|---|---|---|
vigil |
service POWER |
Service | POWER |
login |
service AUTH SETUID |
Service | AUTH, SETUID |
bastion |
service AUTH FB SETUID |
Service | AUTH, FB, SETUID |
stsh |
admin DISK_ADMIN POWER CAP_DELEGATE CAP_QUERY |
Admin | DISK_ADMIN, POWER, CAP_DELEGATE, CAP_QUERY |
admin PROC_READ |
Admin | PROC_READ | |
httpd |
service NET_SOCKET |
Service | NET_SOCKET |
dhcp |
service NET_SOCKET NET_ADMIN |
Service | NET_SOCKET, NET_ADMIN |
lumen |
service FB THREAD_CREATE PROC_READ POWER |
Service | FB, THREAD_CREATE, PROC_READ, POWER |
shutdown |
service PROC_READ POWER |
Service | PROC_READ, POWER |
reboot |
service POWER |
Service | POWER |
nettest |
service NET_SOCKET NET_ADMIN |
Service | NET_SOCKET, NET_ADMIN |
installer |
admin DISK_ADMIN AUTH |
Admin | DISK_ADMIN, AUTH |
gui-installer |
admin DISK_ADMIN AUTH FB |
Admin | DISK_ADMIN, AUTH, FB |
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.
stsh uses admin tier. The shell’s powerful capabilities (DISK_ADMIN, POWER, CAP_DELEGATE, CAP_QUERY) 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, and nettest receive their network capabilities unconditionally. These are system daemons launched by Vigil that need networking to function, regardless of user authentication state.
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, andAUTHshould 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
Policy lookup is based on the executable’s basename, not its full path. This means a binary at /tmp/malicious/login would match the login policy and receive AUTH and SETUID capabilities.
This is mitigated by:
- Filesystem permissions.
/etc/aegis/caps.d/is only writable by root. Creating a binary namedloginin a user-writable directory is possible, but the binary itself must be placed there, which requires write access. - Controlled spawn paths. The Vigil init system and Bastion session manager use absolute paths when spawning services, so the exec path is controlled by trusted code.
- Future work. A path-based policy matching mode (matching on full path rather than basename) is a potential hardening improvement.
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 on the ext2 filesystem. The integrity of the policy engine depends on the integrity of the filesystem. An attacker who can modify files on the root filesystem can alter policy files to grant arbitrary capabilities to any binary. Filesystem integrity protection (e.g., dm-verity, signed root images) is outside the current scope but would strengthen the policy engine’s security guarantees.