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 service or admin
  • 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_SOCKET to open sockets
  • The login program needs AUTH to read /etc/shadow
  • The compositor needs FB to 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_ADMIN and POWER only after login
  • The installer gets DISK_ADMIN and AUTH only 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 entry
  • CAP_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:

  1. No heap allocation. File contents are read into a 512-byte stack buffer. Policy files are expected to be small.
  2. No libc dependency. String comparison uses hand-written streq() and character-by-character matching.
  3. Graceful degradation. If /etc/aegis/caps.d/ does not exist, the engine loads zero policies and all processes run with only baseline capabilities.
  4. Boot ordering. cap_policy_load() must be called after vfs_init() and ext2_mount() because it reads from the filesystem.

Parse Algorithm

The parser (parse_policy) processes the file line by line:

  1. Skip whitespace and newlines.
  2. Skip lines starting with # (comments).
  3. Read the tier word (service or admin). Unknown tiers skip the line.
  4. Read all remaining words on the line as capability names.
  5. Map each name through cap_name_to_kind(). Unknown names produce a warning and are skipped.
  6. Store (kind, rights=0x7, tier) into the entry’s caps[] array.
  7. 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:

  1. Create a file in /etc/aegis/caps.d/ named after the binary’s basename (no extension, no path).

  2. Add lines specifying the tier and capability names:
    # Grant unconditionally
    service NET_SOCKET
    
    # Grant only to authenticated sessions
    admin DISK_ADMIN
    
  3. 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, and AUTH should 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:

  1. Filesystem permissions. /etc/aegis/caps.d/ is only writable by root. Creating a binary named login in a user-writable directory is possible, but the binary itself must be placed there, which requires write access.
  2. 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.
  3. 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.