maelys-pty

Level 2 — Risk model and confirmation

Classify risk, request confirmation, rebuild the context, and evaluate again.

Continue from Level 1 — Line filter with metachar scan. Keep the declarations and helpers from the earlier levels; this page extends the same wrapper in C and Python.

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. This catches stale decisions; it does not make checking and execution atomic or close every time-of-check/time-of-use (TOCTOU) race.

Risk lattice

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

Extended domain

CODEExtend the risk vocabulary
/* Use this complete table instead of pty_preds for Level 2. */
static const maelys_datalog_predicate_t pty_preds_l2[] = {

  /* EDB: populated per command line */
  { "command",        1, MAELYS_DATALOG_PREDICATE_EDB },
  { "arg",            1, MAELYS_DATALOG_PREDICATE_EDB },
  { "user",           1, MAELYS_DATALOG_PREDICATE_EDB },
  { "cwd",            1, MAELYS_DATALOG_PREDICATE_EDB },
  { "time_of_day",    1, MAELYS_DATALOG_PREDICATE_EDB },
  /* Metacharacters detected in the raw line before tokenising */
  { "shell_metachar", 1, MAELYS_DATALOG_PREDICATE_EDB },

  /* POLICY_FACT */
  { "forbidden_cmd",  1, MAELYS_DATALOG_PREDICATE_POLICY_FACT },
  { "trusted_user",   1, MAELYS_DATALOG_PREDICATE_POLICY_FACT },

  /* IDB + QUERY */
  { "deny_any",  1, MAELYS_DATALOG_PREDICATE_IDB | MAELYS_DATALOG_PREDICATE_QUERY },
  { "allow",     1, MAELYS_DATALOG_PREDICATE_IDB | MAELYS_DATALOG_PREDICATE_QUERY },
  { "deny",      2, MAELYS_DATALOG_PREDICATE_IDB | MAELYS_DATALOG_PREDICATE_QUERY },
{ "risk_critical",  1, MAELYS_DATALOG_PREDICATE_POLICY_FACT },
{ "risk_high",      1, MAELYS_DATALOG_PREDICATE_POLICY_FACT },
{ "risk_medium",    1, MAELYS_DATALOG_PREDICATE_POLICY_FACT },
{ "challenge",      2, MAELYS_DATALOG_PREDICATE_IDB | MAELYS_DATALOG_PREDICATE_QUERY },
{ "allow_with_log", 2, MAELYS_DATALOG_PREDICATE_IDB | MAELYS_DATALOG_PREDICATE_QUERY },
{ "confirmed",     1, MAELYS_DATALOG_PREDICATE_EDB },
{ "challenge_any", 1, MAELYS_DATALOG_PREDICATE_IDB },
};

The Level 2 policy

DATALOG
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)), not(confirmed(Cmd)).

challenge_any(Cmd) :- challenge(Cmd, Reason).

allow_with_log(Cmd, "medium-risk executed") :-
    command(Cmd), risk_medium(Cmd),
    not(deny_any("__line__")),
    not(deny_any(Cmd)), not(challenge_any(Cmd)).

allow(Cmd) :-
    command(Cmd),
    not(deny_any("__line__")),
    not(deny_any(Cmd)),
    not(challenge_any(Cmd)),
    not(risk_medium(Cmd)).

Confirm, rebuild the context, then evaluate again

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). Rebuild the context after receiving y, record confirmed(Cmd) in that new request, and evaluate again. Confirmation suppresses the challenge, not a denial. Level 2 replaces the Level 1 allow rule; retaining the old rule would bypass the challenge. Retain the Level 1 policy facts, deny rules and deny_any projection. The direct risk_medium test routes medium-risk decisions through logging without adding an unnecessary negation layer; this policy fits the SMALL profile.

CODERe-evaluate after confirmation
/* Application callback: rebuild current evidence, insert confirmed(Cmd),
   check all errors, solve, and query allow(Cmd). This is NOT an engine API. */
typedef int (*confirmed_evaluator_t)(void *context, const char *line, size_t len);

static int handle_challenge(void *context, const char *line, size_t len,
                            confirmed_evaluator_t evaluate_confirmed)
{
    if (evaluate_confirmed == NULL) return 0;
    fputs("maelys-pty: high-risk — confirm? [y/N] ", stderr);
    if (fflush(stderr) != 0) return 0;
    char answer[8];
    if (fgets(answer, sizeof(answer), stdin) == NULL) return 0;
    if (strcmp(answer, "y\n") != 0 && strcmp(answer, "Y\n") != 0) return 0;
    return evaluate_confirmed(context, line, len) == 1;
}

Level 2 in action

CODE
bob@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

What you have learned

Keep this model

Treat confirmation as new evidence, not permission to bypass policy
  • Risk determines the next action

    The rules distinguish denial, confirmation, logged permission and ordinary permission. A challenge is not yet an authorization.

  • Rebuild the facts after the response

    A confirmation adds evidence to a fresh context, which is evaluated again. It must not override an applicable denial.

  • Re-evaluation is not atomic execution

    Checking again reduces stale decisions but does not eliminate a race between checking and executing. That boundary remains the responsibility of the native monitor.

Continue