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, 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_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(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 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)
{
    /* 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:

  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, AUTH, and INSTALL 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 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.