#!/usr/bin/env bash
# session-class.sh — the single definition of "a human at a TTY" vs "an agent runtime".
#
# Sourced by bin/_agent-unsafe.sh (which refuses agents) and bin/_human-session (which
# grants agents nothing and humans the protected slice). Both must agree, so the rules
# live here once.
#
# The launch ancestry's cgroups decide: confinement places agents in agent.slice or a
# dispatch scope, hosted human sessions in human.slice, and the owner's terminals in a
# terminal scope. Environment is deliberately NOT part of this decision: markers cross
# exec boundaries and leaked agent markers have locked the owner out of the terminal
# escape hatches. /proc/<pid>/cgroup is the authoritative launch ancestry.
#
# Ambiguity resolves to AGENT when no controlling terminal is present. A human
# misclassified as an agent gets today's confinement; an agent misclassified as a human
# gets an uncapped slice, which is how a fork bomb would reach the desktop.

_session_result() {
  if [[ -n ${2:-} ]]; then printf -v "$2" '%s' "$1"; else printf '%s' "$1"; fi
}

session_container_signal() { # prints the signal; 1 when not in a container
  local line signal="" output="${1:-}"
  if [[ -e /run/.containerenv ]]; then
    signal='file:/run/.containerenv'
  elif [[ -e /.dockerenv ]]; then
    signal='file:/.dockerenv'
  elif [[ -n "${container:-}" ]]; then
    signal="env:container=$container"
  elif [[ -r /proc/self/cgroup ]]; then
    read -r line </proc/self/cgroup || line=""
    [[ "$line" =~ (libpod|docker|containerd|/machine\.slice/|kubepods) ]] &&
      signal='cgroup:/proc/self/cgroup'
  fi
  [[ -n "$signal" ]] || return 1
  _session_result "$signal" "$output"
}

# Cgroup path fragments read from live processes on this machine: agents run in
# agent.slice/confine-agent-*.scope or a *dispatch*.scope, a hosted human session in
# human.slice, and a human's terminal in app.slice/app-*.slice/vte-spawn-*.scope or
# user-1000.slice/session-*.scope.
SESSION_AGENT_CGROUP_RE='/agent\.slice(/|$)'
SESSION_DISPATCH_CGROUP_RE='/[^/]*[Dd]ispatch[^/]*\.scope(/|$)'
SESSION_HUMAN_CGROUP_RE='/human\.slice(/|$)'
SESSION_TERMINAL_CGROUP_RE='(/app\.slice(/|$)|/session-[0-9]+\.scope(/|$))'
SESSION_ANCESTRY_MAX=64

session_cgroup_of() { # $1=pid; prints its cgroup v2 path
  local pid="$1" line output="${2:-}"
  read -r line <"/proc/$pid/cgroup" 2>/dev/null || return 1
  [[ -n "$line" ]] || return 1
  _session_result "${line#0::}" "$output"
}

session_ppid_of() { # $1=pid; prints its parent pid. comm may contain spaces and ')'.
  local pid="$1" stat output="${2:-}"
  read -r stat <"/proc/$pid/stat" 2>/dev/null || return 1
  stat="${stat##*') '}"
  # shellcheck disable=SC2086
  set -- $stat
  _session_result "${2:-}" "$output"
}

session_cgroup_ancestry() { # prints "<pid>\t<cgroup>" for this process and each ancestor
  local pid=$$ depth=0 cg next
  while [[ "$pid" =~ ^[0-9]+$ ]] && (( pid > 1 && depth < SESSION_ANCESTRY_MAX )); do
    session_cgroup_of "$pid" cg && printf '%s\t%s\n' "$pid" "$cg"
    session_ppid_of "$pid" next || break
    pid=$next
    depth=$((depth + 1))
  done
}

session_controlling_tty() { # 0 when this process has a controlling terminal
  local stat
  read -r stat <"/proc/self/stat" 2>/dev/null || return 1
  stat="${stat##*') '}"
  # shellcheck disable=SC2086
  set -- $stat
  [[ "${5:-0}" =~ ^[0-9]+$ ]] && (( $5 != 0 ))
}

session_cgroup_verdict_from_paths() {
  local paths="$1" output="${2:-}" cg human='' terminal='' result=''
  while IFS= read -r cg; do
    [[ -n "$cg" ]] || continue
    if [[ "$cg" =~ $SESSION_AGENT_CGROUP_RE || "$cg" =~ $SESSION_DISPATCH_CGROUP_RE ]]; then
      result="agent:$cg"
      break
    fi
    [[ -z "$human" && "$cg" =~ $SESSION_HUMAN_CGROUP_RE ]] && human="$cg"
    [[ -z "$terminal" && "$cg" =~ $SESSION_TERMINAL_CGROUP_RE ]] && terminal="$cg"
  done <<<"$paths"
  [[ -z "$result" && -n "$human" ]] && result="human:$human"
  [[ -z "$result" && -n "$terminal" ]] && result="terminal:$terminal"
  [[ -n "$result" ]] || return 1
  _session_result "$result" "$output"
}

session_cgroup_verdict() { # prints agent:|human:|terminal:<cgroup>; 1 when the ancestry proves none
  local pid=$$ depth=0 cg next paths='' output="${1:-}"
  while [[ "$pid" =~ ^[0-9]+$ ]] && (( pid > 1 && depth < SESSION_ANCESTRY_MAX )); do
    session_cgroup_of "$pid" cg && paths+="$cg"$'\n'
    session_ppid_of "$pid" next || break
    pid=$next
    depth=$((depth + 1))
  done
  session_cgroup_verdict_from_paths "$paths" "$output"
}

session_is_human() { # 0 when a human is at a terminal; 1 otherwise, reason on stdout
  local detail verdict=''
  session_cgroup_verdict verdict || verdict=''
  if [[ "$verdict" == agent:* ]]; then
    printf 'agent-cgroup:%s' "${verdict#agent:}"
    return 1
  fi
  if session_container_signal detail; then
    printf 'in-container:%s' "$detail"
    return 1
  fi
  # A hosted human session is proof on its own; a terminal scope also hosts cron and timer
  # units, so there a controlling terminal is what separates the owner from a scripted
  # launch. The controlling terminal is read from /proc because callers redirect this
  # function's stdout, which would make `-t 1` false for the owner too.
  [[ "$verdict" == human:* ]] && return 0
  if session_controlling_tty; then
    return 0
  fi
  # A process shelling out has no controlling terminal; a human typing this does.
  printf 'no-tty'
  return 1
}
