# tool-confine.sh — migrate a Claude Code Bash tool shell into agent.slice.
#
# Sourced from the tail of every shell snapshot in ~/.claude/shell-snapshots/, which
# every Bash tool call sources before running its command. That is the only injection
# point independent of how the session was launched (terminal, resume, or hand-moved
# during a rescue), so coverage does not depend on command shape or on cpu-guard
# classifying the command as heavy.
#
# Only the tool shell moves. The session process is the tool shell's PARENT and is never
# touched: it stays in vte-spawn-*/session-N.scope/human.slice, which is_killable()
# refuses to kill.
#
# Fail-open by construction: every failure path returns 0 and the tool command still runs
# unconfined. A missing scope must never cost the user a tool call.
#
# Kill switch: CONFINE_TOOL_SHELL=0.
__overdeck_tool_confine() {
  [[ "${CONFINE_TOOL_SHELL:-1}" == 0 ]] && return 0

  local cgroup
  cgroup=$(tr -d '\0' < /proc/self/cgroup 2>/dev/null) || return 0
  # Already inside a confine scope (nested tool shell, or a re-source of the snapshot):
  # a second migration would strand the first scope with no pids.
  [[ "$cgroup" == */confine-* ]] && return 0

  command -v busctl >/dev/null 2>&1 || return 0

  # StartTransientUnit with a PIDs property migrates the calling process itself, so the
  # ceiling applies to every later fork without re-exec'ing the command (which would
  # break the harness cwd trailer and collide with the command-rewriting hooks).
  # Name matches KILLABLE_SCOPE_RE in modules/monitor/lib/pids_cgroup.py; Slice is
  # mandatory — the default places the scope under app.slice, which HUMAN_COMPONENT_RE
  # marks unkillable, so the guard could never stop a runaway started there.
  local unit="confine-agent-$$-${RANDOM}.scope"
  busctl --user call org.freedesktop.systemd1 /org/freedesktop/systemd1 \
    org.freedesktop.systemd1.Manager StartTransientUnit "ssa(sv)a(sa(sv))" \
    "$unit" fail 5 \
    Slice s agent.slice \
    PIDs au 1 $$ \
    TasksMax t "${AGENT_TASKS_MAX:-1024}" \
    OOMPolicy s continue \
    CollectMode s inactive-or-failed \
    0 >/dev/null 2>&1 || return 0

  # The call returns once the job is enqueued; the move lands slightly later. Without this
  # wait the command's own first fork can still start in the old cgroup — which is exactly
  # the process a runaway would be. Typically 0.5-15ms; the deadline covers a user manager
  # busy with a daemon-reload storm, after which the command runs unconfined rather than
  # waiting any longer.
  # EPOCHREALTIME's separator is the locale's decimal point, so strip either form.
  local deadline=$(( ${EPOCHREALTIME/[.,]/} + 250000 )) cg spins=0
  while (( ${EPOCHREALTIME/[.,]/} < deadline )); do
    read -r cg < /proc/self/cgroup 2>/dev/null || break
    case "$cg" in *"$unit") break ;; esac
    # Spin only for the common sub-millisecond case; yield rather than burn a core
    # when the manager is slow, since this runs on every tool call.
    if (( ++spins > 200 )); then sleep 0.01 2>/dev/null || break; fi
  done

  return 0
}
__overdeck_tool_confine
unset -f __overdeck_tool_confine
