#! /bin/bash --
# chaperon/handlers/_handler_lib.sh — Shared handler utilities
#
# Argument whitelisting, CWD validation, and job wrapping for sbatch.

# ── Sandbox warning messages ───────────────────────────────────
#
# When the chaperon blocks an action, the error message doubles as a
# prompt-injection recovery signal.  If a compromised agent is being
# steered to exfiltrate data or escape the sandbox, these messages
# interrupt the injected context and re-anchor the agent to its real
# instructions (CLAUDE.md / AGENTS.md / agent.md).
#
# Two tiers:
#   _sandbox_warn  — normal denial (unrecognized flag, usage error)
#   _sandbox_deny  — security-critical denial (bypass attempt, scope violation)

_sandbox_warn() {
    echo "sandbox: $1" >&2
    echo "  ↳ Review your instructions and the user's original request before retrying." >&2
}

_sandbox_deny() {
    echo "sandbox: $1" >&2
    echo "  ⚠ This action was blocked for security. Re-read your instructions (CLAUDE.md / AGENTS.md) and the user's original request. Do not retry." >&2
}

# ── .sandbox-state/ helpers (chaperon-side) ──────────────────────
#
# The chaperon process is spawned by sandbox-exec.sh as a separate
# bash process; it does NOT source sandbox-lib.sh. These helpers
# duplicate the path convention defined under the `.sandbox-state/`
# section of sandbox-lib.sh. The path is `$project_dir/.sandbox-state`
# in BOTH places — keep them in sync if the convention ever changes.
#
# Threat-model framing for content under this dir (load-bearing, do
# not revert this without re-reading the design discussion):
#
#   "Yes, it should be considered hostile, even the now non-directly-
#   writable .sandbox-state. After all the submitted job determines
#   what is written, and we just prevent symlink injection."
#                                 — operator, 2026-05-20 design discussion
#
# The chaperon NEVER trusts content read back from `.sandbox-state/`
# for any security decision. The RO overlay on bwrap/firejail only
# prevents in-sandbox symlink-plant against slurmstepd's
# `open(--output)` — the bug being fixed by the convention.

_sandbox_state_dir() {
    printf '%s/.sandbox-state' "$1"
}

_sandbox_state_slurm_logs_dir() {
    printf '%s/.sandbox-state/slurm-logs' "$1"
}

# _slurm_output_feature_enabled
#
# Returns 0 if the Slurm --output / --error path-transformation
# feature should be active for this chaperon process. Disabled on
# landlock (no RO-overlay mechanism — symlink-plant defense
# unavailable, see backends/landlock.sh's note). $SANDBOX_BACKEND
# is exported by sandbox-exec.sh after detect_backend specifically
# so the chaperon can branch on it here.
_slurm_output_feature_enabled() {
    case "${SANDBOX_BACKEND:-}" in
        bwrap|firejail) return 0 ;;
        *) return 1 ;;
    esac
}

# _ensure_sandbox_state_dir <project_dir>
#
# Idempotent mkdir of `.sandbox-state/{slurm-logs,chaperon}` with a
# README.md marker so operators encountering the dir for the first
# time can decode it without grepping. Called from sbatch.sh before
# computing the staging path. Owner-only perms — these dirs hold log
# content that may carry user data.
_ensure_sandbox_state_dir() {
    local _project_dir="$1"
    local _state_dir="$_project_dir/.sandbox-state"

    # Do NOT short-circuit on `[[ -d $_state_dir ]]`: chaperon/logging.sh
    # mkdir's `.sandbox-state/chaperon/` early in chaperon startup, which
    # creates `.sandbox-state/` itself. Short-circuiting then would skip
    # mkdir'ing `slurm-logs/` and slurmstepd's open(--output) would fail
    # with ENOENT. `mkdir -p` is already idempotent — re-run is cheap.
    mkdir -p "$_state_dir/slurm-logs" "$_state_dir/chaperon" 2>/dev/null || return 1
    chmod 700 "$_state_dir" "$_state_dir/slurm-logs" "$_state_dir/chaperon" 2>/dev/null || true

    local _marker="$_state_dir/README.md"
    if [[ ! -e "$_marker" ]]; then
        cat > "$_marker" <<'_SANDBOX_STATE_README' 2>/dev/null || true
# .sandbox-state/

Hidden chaperon-owned state directory created by `agent-sandbox`.
Do not modify by hand — content is managed by the chaperon process
running outside the sandbox.

## Contents

- `slurm-logs/<path>` — `sbatch --output` / `--error` are redirected
  here so slurmstepd writes inside the project tree (cross-node
  accessible via NFS) while the bwrap/firejail bind-mount layer
  prevents in-sandbox symlink-plant against slurmstepd's `open()`.
  The wrapper inside the sandbox creates relative symlinks from the
  user's intended output paths to files here.

- `chaperon/<session-id>/log` — chaperon diagnostic log (one
  subdirectory per chaperon process; disambiguates concurrent
  sandboxes in the same project).

## Writability matrix

| Principal | Permission | Why |
|---|---|---|
| host (chaperon, slurmstepd) | read+write | chaperon mkdir's; slurmstepd writes |
| sandbox (bwrap/firejail)    | read-only  | bind-mount overlay; prevents symlink-plant |
| sandbox (landlock)          | writable   | landlock can't make subdir RO under RW parent; the slurm-output feature is disabled there |

## Lifecycle

Keep forever (sandbox artifact). To reclaim: `rm -rf .sandbox-state/`.

See `docs/reference/sandbox-state-dir.md` in the agent-sandbox source
tree for the full convention and the threat-model framing.
_SANDBOX_STATE_README
        chmod 644 "$_marker" 2>/dev/null || true
    fi
    return 0
}

# ── Slurm --output / --error path transformation ─────────────────
#
# Transform a user-supplied --output / --error value into an absolute
# path under `$project_dir/.sandbox-state/slurm-logs/` such that:
#
#   - Absolute paths are encoded under `__abs__/` (escape encoded so
#     the wrapper can reverse the transform and re-prepend `/`).
#   - `..` path components are renamed to `__updir__` (escape
#     contained — `__updir__` is a literal directory name, can't
#     traverse out of the staging subtree).
#   - `.` and empty components are dropped (path normalisation).
#   - `%`-patterns (`%j`, `%A`, `%a`, `%N`, `%u`, `%x`, `%t`) survive
#     intact; slurmstepd substitutes them at file-open time, so the
#     resolved on-disk staging path becomes the runtime location.
#
# Why transform rather than validate-and-reject: the staging dir is
# bind-mounted read-only inside the sandbox (bwrap/firejail), so an
# agent can't symlink-plant against slurmstepd's `open(--output)` —
# the actual escape vector this closes. Validation would still be
# brittle for `%`-patterns and produces a UX cliff when the user
# innocently submits `--output=/scratch/foo` (rejected vs. silently
# redirected, with a symlink at the intended path resolving to the
# staging file for the cases the agent CAN write to).
#
# Echoes the transformed absolute path on stdout. Pure function;
# table-testable via test.sh.
_transform_slurm_output_path() {
    local _value="$1"
    local _project_dir="$2"

    # Trim ambient whitespace (defensive).
    _value="${_value#"${_value%%[![:space:]]*}"}"
    _value="${_value%"${_value##*[![:space:]]}"}"

    local _state_logs="$_project_dir/.sandbox-state/slurm-logs"

    # Empty input: caller is expected to skip the transform; defensive
    # return of the staging root keeps the function total.
    [[ -z "$_value" ]] && { printf '%s' "$_state_logs"; return; }

    # Detect absolute → encode original-was-absolute as `__abs__/`
    # prefix so the wrapper can re-prepend `/` on the way out.
    local _is_abs=false
    if [[ "$_value" == /* ]]; then
        _is_abs=true
        while [[ "$_value" == /* ]]; do _value="${_value#/}"; done
    fi

    # Split on / and process each component. `..` → `__updir__` only
    # at exact-component match; `..foo` is left alone (legitimate
    # filename).
    local _out_components=()
    if $_is_abs; then
        _out_components+=("__abs__")
    fi
    local _saved_ifs="$IFS"
    IFS='/'
    # shellcheck disable=SC2206  # split on / is intentional
    local _parts=( $_value )
    IFS="$_saved_ifs"
    local _p
    for _p in "${_parts[@]}"; do
        case "$_p" in
            "")  ;;                                  # collapse //
            ".") ;;                                  # drop no-op
            "..") _out_components+=("__updir__") ;;  # contain escape
            *)   _out_components+=("$_p") ;;
        esac
    done

    local _saved_ifs2="$IFS"
    IFS='/'
    local _transformed="${_out_components[*]}"
    IFS="$_saved_ifs2"

    if [[ -z "$_transformed" ]]; then
        printf '%s' "$_state_logs"
    else
        printf '%s/%s' "$_state_logs" "$_transformed"
    fi
}

# ── Whitelisted sbatch flags ────────────────────────────────────
# Only these flags are forwarded to the real sbatch. This is a security
# boundary: flags that could bypass sandboxing are excluded.
#
# Handled by stub/protocol (not denied — intercepted before reaching here):
#   --wrap        — stub converts to SCRIPT in protocol
#
# Denied (security-critical):
#   --chdir / -D  — CWD comes from stub's pwd, validated against project dir
#   --uid / --gid — must not impersonate other users
#   --get-user-env — can leak host environment
#   --propagate   — can propagate unsafe rlimits
#   (--export is allowed: compute-node jobs run inside sandbox-exec.sh
#   which filters env vars regardless of what --export passes)
#   --prolog / --epilog / --task-prolog / --task-epilog — run arbitrary scripts
#   --burst-buffer-file / --bbf — arbitrary file access
#   --bcast       — copy binary to nodes (bypass wrapping)
#   --container   — OCI containers bypass sandbox
#
# Format: space-delimited, both short and long forms.
# Flags that take a value are marked with "=" suffix in _SBATCH_VALUE_FLAGS.

_SBATCH_ALLOWED_FLAGS=" \
  -A --account \
  -c --cpus-per-task \
  -d --dependency \
  -e --error \
  -H --hold \
  -J --job-name \
  -n --ntasks \
  -N --nodes \
  -o --output \
  -p --partition \
  -q --qos \
  -t --time \
  -G --gpus \
  -w --nodelist \
  -x --exclude \
  --begin \
  --comment \
  --constraint \
  --contiguous \
  --cpu-freq \
  --deadline \
  --exclusive \
  --export \
  --gres \
  --gpus-per-node \
  --gpus-per-task \
  --mail-type \
  --mail-user \
  --mem \
  --mem-per-cpu \
  --mem-per-gpu \
  --nice \
  --ntasks-per-node \
  --cpus-per-gpu \
  --overcommit \
  --oversubscribe \
  --priority \
  --requeue \
  --no-requeue \
  --reservation \
  --signal \
  --switches \
  --threads-per-core \
  --tmp \
  --verbose \
  --wait \
  --wait-all-nodes \
  --wckey \
  --array \
  --parsable \
  --test-only \
  --help \
  --usage \
  --version \
"

# Flags that consume a value argument (space-separated form: --flag value)
_SBATCH_VALUE_FLAGS=" \
  -A --account \
  -c --cpus-per-task \
  -d --dependency \
  -e --error \
  -J --job-name \
  -n --ntasks \
  -N --nodes \
  -o --output \
  -p --partition \
  -q --qos \
  -t --time \
  -G --gpus \
  -w --nodelist \
  -x --exclude \
  --begin \
  --comment \
  --constraint \
  --cpu-freq \
  --deadline \
  --export \
  --gres \
  --gpus-per-node \
  --gpus-per-task \
  --mail-type \
  --mail-user \
  --mem \
  --mem-per-cpu \
  --mem-per-gpu \
  --nice \
  --ntasks-per-node \
  --cpus-per-gpu \
  --priority \
  --reservation \
  --signal \
  --switches \
  --threads-per-core \
  --tmp \
  --wait-all-nodes \
  --wckey \
  --array \
"

# Check if a flag is in the allowed list.
_is_allowed_flag() {
    local flag="$1"
    # Strip =value for --flag=value forms
    local base="${flag%%=*}"
    [[ "$_SBATCH_ALLOWED_FLAGS" == *" $base "* ]]
}

# Security-critical deny-list (ASB-2026-001). Shared between
# validate_sbatch_args (CLI) and create_wrapped_script (#SBATCH
# directive filter) so both entry points reject identically. An
# attacker who can put `--task-prolog` on the CLI is blocked by
# validate_sbatch_args; an attacker who hides `--task-prolog`
# inside a #SBATCH directive body must be blocked here too.
_is_denied_flag() {
    local base="${1%%=*}"
    case "$base" in
        --prolog|--epilog|--task-prolog|--task-epilog) return 0 ;;
        --get-user-env)                                return 0 ;;
        --bcast)                                       return 0 ;;
        --container)                                   return 0 ;;
        --uid|--gid)                                   return 0 ;;
        --propagate)                                   return 0 ;;
        --burst-buffer-file|--bbf)                     return 0 ;;
        --wrap)                                        return 0 ;;
        --chdir|-D)                                    return 0 ;;
    esac
    return 1
}

# Check if a flag consumes a value argument.
_is_value_flag() {
    [[ "$_SBATCH_VALUE_FLAGS" == *" $1 "* ]]
}

# ── Argument validation ─────────────────────────────────────────

# _maybe_transform_slurm_output_arg <flag> <value> <project_dir>
#
# Pure-stdout helper for validate_sbatch_args and the #SBATCH-directive
# filter in create_wrapped_script. Given an --output / --error flag-name
# and raw value, returns the value-to-forward-to-sbatch on stdout.
#
# Deliberately side-effect-free: callers invoke via $(...) command
# substitution, which would lose any global-variable side-effects to a
# subshell. The (user, staging) pair is recorded by the caller via
# `_capture_slurm_output_pair` in the caller's own scope.
#
# If the feature is disabled (landlock) or value is empty, returns the
# value unchanged — the user's path flows verbatim to real sbatch.
_maybe_transform_slurm_output_arg() {
    local _flag="$1" _value="$2" _project_dir="$3"
    if ! _slurm_output_feature_enabled || [[ -z "$_value" ]]; then
        printf '%s' "$_value"
        return
    fi
    case "$_flag" in
        -o|--output|-e|--error) ;;
        *) printf '%s' "$_value"; return ;;
    esac
    _transform_slurm_output_path "$_value" "$_project_dir"
}

# _capture_slurm_output_pair <flag> <user_value> <staging_value>
#
# Caller-scope companion to _maybe_transform_slurm_output_arg. Records
# the (user-template, staging-template) pair into the per-stream capture
# vars that `create_wrapped_script` reads to emit the in-sandbox symlink
# prelude. Multiple occurrences: later wins (matches Slurm's
# last-occurrence semantics).
#
# Must be called in the caller's scope (NOT inside `$(...)`), since the
# whole point is to mutate variables visible to the wrapper-building code.
# Silently no-ops when the feature is disabled or the user value is empty
# — same gates as the transform helper.
_capture_slurm_output_pair() {
    local _flag="$1" _user="$2" _staging="$3"
    _slurm_output_feature_enabled || return 0
    [[ -z "$_user" ]] && return 0
    case "$_flag" in
        -o|--output) _USER_SLURM_OUTPUT="$_user"; _STAGING_SLURM_OUTPUT="$_staging" ;;
        -e|--error)  _USER_SLURM_ERROR="$_user";  _STAGING_SLURM_ERROR="$_staging"  ;;
    esac
}

# Validate and filter sbatch arguments.
# Input:  REQ_ARGS array (from protocol), PROJECT_DIR (caller scope)
# Output: VALIDATED_ARGS array (safe to pass to real sbatch)
#         _USER_SLURM_OUTPUT / _STAGING_SLURM_OUTPUT (and ERROR
#         counterparts) — populated when --output / --error are
#         present AND the feature is enabled (bwrap/firejail).
# Returns 1 if a denied flag is found.
validate_sbatch_args() {
    VALIDATED_ARGS=()
    _USER_COMMENT=""   # Captured here, injected by sbatch handler with chaperon tag
    _USER_SLURM_OUTPUT=""
    _USER_SLURM_ERROR=""
    _STAGING_SLURM_OUTPUT=""
    _STAGING_SLURM_ERROR=""
    local _project_dir="${PROJECT_DIR:-}"
    local i=0
    while (( i < ${#REQ_ARGS[@]} )); do
        local arg="${REQ_ARGS[$i]}"
        case "$arg" in
            --wrap|--wrap=*)
                _sandbox_warn "sbatch '--wrap' is handled automatically. Pass your script as a file argument or use --wrap normally."
                return 1
                ;;
            --chdir|--chdir=*|-D)
                _sandbox_warn "sbatch '--chdir' is not allowed — the working directory is set automatically to your current directory."
                return 1
                ;;
            --output=*|-o=*|--error=*|-e=*)
                # `=` form: extract value, transform, capture, re-pack.
                local _flag="${arg%%=*}"
                local _v="${arg#*=}"
                local _t
                _t="$(_maybe_transform_slurm_output_arg "$_flag" "$_v" "$_project_dir")"
                _capture_slurm_output_pair "$_flag" "$_v" "$_t"
                VALIDATED_ARGS+=("$_flag=$_t")
                ;;
            --output|-o|--error|-e)
                # space form: --output <value>.
                if (( i + 1 < ${#REQ_ARGS[@]} )); then
                    (( i++ ))
                    local _v="${REQ_ARGS[$i]}"
                    local _t
                    _t="$(_maybe_transform_slurm_output_arg "$arg" "$_v" "$_project_dir")"
                    _capture_slurm_output_pair "$arg" "$_v" "$_t"
                    VALIDATED_ARGS+=("$arg" "$_t")
                else
                    _sandbox_warn "sbatch '$arg' requires a value."
                    return 1
                fi
                ;;
            --uid|--uid=*|--gid|--gid=*)
                _sandbox_deny "sbatch '--uid/--gid' is not allowed — jobs must run as your own user."
                return 1
                ;;
            --get-user-env|--get-user-env=*)
                _sandbox_deny "sbatch '--get-user-env' is not allowed — it can leak environment variables from outside the sandbox."
                return 1
                ;;
            --propagate|--propagate=*)
                _sandbox_deny "sbatch '--propagate' is not allowed — resource limit propagation is restricted for security."
                return 1
                ;;
            --prolog|--prolog=*|--epilog|--epilog=*|--task-prolog|--task-prolog=*|--task-epilog|--task-epilog=*)
                _sandbox_deny "sbatch '--prolog/--epilog' is not allowed — custom prolog/epilog scripts could run outside sandbox control."
                return 1
                ;;
            --burst-buffer-file|--burst-buffer-file=*|--bbf|--bbf=*)
                _sandbox_deny "sbatch '--burst-buffer-file' is not allowed — arbitrary file access is restricted."
                return 1
                ;;
            --bcast|--bcast=*)
                _sandbox_deny "sbatch '--bcast' is not allowed — binary broadcasting could bypass sandbox wrapping."
                return 1
                ;;
            --container|--container=*)
                _sandbox_deny "sbatch '--container' is not allowed — OCI containers would bypass sandbox restrictions."
                return 1
                ;;
            --comment=*)
                # Intercept --comment: chaperon will inject its own tag.
                # Save the user's value to append later.
                _USER_COMMENT="${arg#--comment=}"
                ;;
            --comment)
                # --comment <value> form
                if (( i + 1 < ${#REQ_ARGS[@]} )); then
                    (( i++ ))
                    _USER_COMMENT="${REQ_ARGS[$i]}"
                fi
                ;;
            --*=*)
                if _is_allowed_flag "$arg"; then
                    VALIDATED_ARGS+=("$arg")
                else
                    _sandbox_warn "sbatch flag '${arg%%=*}' is not recognized. Only whitelisted flags are allowed inside the sandbox."
                    return 1
                fi
                ;;
            -*)
                if _is_allowed_flag "$arg"; then
                    VALIDATED_ARGS+=("$arg")
                    if _is_value_flag "$arg" && (( i + 1 < ${#REQ_ARGS[@]} )); then
                        (( i++ ))
                        VALIDATED_ARGS+=("${REQ_ARGS[$i]}")
                    fi
                else
                    _sandbox_warn "sbatch flag '$arg' is not recognized. Only whitelisted flags are allowed inside the sandbox."
                    return 1
                fi
                ;;
            *)
                _sandbox_warn "sbatch unexpected positional argument. Script files are handled by the stub — this should not happen."
                return 1
                ;;
        esac
        (( i++ ))
    done
    return 0
}

# ── CWD validation ──────────────────────────────────────────────

# Validate that the requested CWD is under the project directory.
# Usage: validate_cwd <cwd> <project_dir>
validate_cwd() {
    local cwd="$1" project_dir="$2"

    # Resolve to physical path (no symlink tricks)
    local resolved
    resolved="$(cd "$cwd" 2>/dev/null && pwd -P)" || {
        _sandbox_warn "working directory does not exist: $cwd"
        return 1
    }

    local resolved_project
    resolved_project="$(cd "$project_dir" 2>/dev/null && pwd -P)" || {
        _sandbox_warn "project directory does not exist: $project_dir"
        return 1
    }

    if [[ "$resolved" != "$resolved_project" && "$resolved" != "$resolved_project"/* ]]; then
        _sandbox_deny "working directory '$resolved' is outside the project directory '$resolved_project'. Jobs must run within the project."
        return 1
    fi
    return 0
}

# ── Job wrapping ────────────────────────────────────────────────

# Detect whether an interpreter line is a POSIX-ish shell that supports
# `-s --` (read script from stdin, treat following args as positionals).
# Handles plain `/bin/bash`, `/bin/bash -e -u`, and `/usr/bin/env bash`.
# Returns 0 for shell, 1 for non-shell or unrecognized.
_is_shell_interpreter() {
    local line="$1"
    # shellcheck disable=SC2206  # word-splitting on the shebang is intentional
    local tokens=($line)
    local first="${tokens[0]:-}"
    [[ -z "$first" ]] && return 1
    local first_base
    first_base="$(basename -- "$first")"
    case "$first_base" in
        bash|sh|zsh|dash|ksh|ash) return 0 ;;
        env)
            # Walk past env's own flags / VAR=val assignments to find the
            # real interpreter token. Conservative: any flag we don't
            # explicitly recognize as boolean is treated as taking a value.
            local i=1
            while (( i < ${#tokens[@]} )); do
                local t="${tokens[$i]}"
                case "$t" in
                    --) (( i++ )); break ;;
                    -i|--ignore-environment|-0|--null|-v|--debug|-S*|--split-string=*)
                        (( i++ )) ;;
                    -u|--unset|-C|--chdir)
                        (( i += 2 )) ;;
                    -*)
                        (( i += 2 )) ;;
                    *=*)
                        (( i++ )) ;;
                    *)
                        break ;;
                esac
            done
            local env_target="${tokens[$i]:-}"
            [[ -z "$env_target" ]] && return 1
            local env_base
            env_base="$(basename -- "$env_target")"
            case "$env_base" in
                bash|sh|zsh|dash|ksh|ash) return 0 ;;
            esac
            return 1
            ;;
    esac
    return 1
}

# Create a wrapped sbatch script that runs the user's command inside
# the sandbox on the compute node.
# Usage: create_wrapped_script <sandbox_exec> <project_dir> <script_content> <output_file> [script_arg ...]
#
# The trailing positional script_args are forwarded so that $1/$@/$# work
# inside the user's wrapped script. For shell shebangs the script is piped
# via stdin and `-s -- arg1 arg2 ...` carries the positionals; for other
# interpreters (python, perl, R, …) the script is materialised to a tmpfile
# inside the sandbox at runtime and exec'd directly so its argv is correct.
create_wrapped_script() {
    local sandbox_exec="$1" project_dir="$2" script_content="$3" output_file="$4"
    shift 4
    local script_args=("$@")

    # Filter #SBATCH directives: keep safe ones, strip dangerous ones.
    # This prevents bypassing the flag whitelist (e.g. #SBATCH --uid=0,
    # #SBATCH --prolog=/evil.sh) while preserving
    # legitimate resource directives (--mem, --partition, --time, etc.).
    local safe_directives=""
    local stripped_count=0
    while IFS= read -r line; do
        # Normalize: strip leading whitespace/tabs before checking.
        # Slurm accepts leading whitespace before #SBATCH directives.
        local trimmed="${line#"${line%%[! 	]*}"}"
        if [[ "$trimmed" == "#SBATCH"* ]]; then
            # Extract the flag from the directive
            local directive_body="${trimmed#\#SBATCH}"
            directive_body="${directive_body# }"  # strip leading space
            # Get the flag name (before = or space)
            local flag_name
            case "$directive_body" in
                --*=*) flag_name="${directive_body%%=*}" ;;
                --*)   flag_name="${directive_body%% *}" ;;
                -*)    flag_name="${directive_body:0:2}" ;;
                *)     flag_name="" ;;
            esac

            # ── ASB-2026-001 defense-in-depth ──────────────────────
            # Slurm's #SBATCH directive parser whitespace-tokenizes
            # the body, so `#SBATCH --time=01 --task-prolog=/evil.sh`
            # would apply BOTH flags despite our flag-name extraction
            # stopping at the first `=`. Refuse any directive whose
            # body contains `[[:space:]]--` — the smuggling-shape
            # signature. Single-flag-per-directive is the Slurm
            # convention; multi-flag lines are rare in practice and
            # always recoverable by splitting onto separate lines.
            if [[ "$directive_body" == *[[:space:]]--* ]]; then
                _sandbox_deny "#SBATCH directive '${directive_body}' contains multiple flag tokens; only one flag per #SBATCH line is allowed (whitespace-smuggling defense, ASB-2026-001)."
                stripped_count=$((stripped_count + 1))
                continue
            fi

            # Explicit security-critical deny-list parity with the
            # CLI surface (validate_sbatch_args). Without this a
            # bare `#SBATCH --task-prolog=/x` would be silently
            # stripped via the allow-list miss below — operators
            # deserve a specific message, and a future allow-list
            # expansion must not silently re-admit a denied flag.
            if [[ -n "$flag_name" ]] && _is_denied_flag "$flag_name"; then
                _sandbox_deny "#SBATCH '${flag_name}' is not allowed — denied by the chaperon's security flag list."
                stripped_count=$((stripped_count + 1))
                continue
            fi

            if [[ -n "$flag_name" ]] && _is_allowed_flag "$flag_name"; then
                # Transform --output / --error values inside #SBATCH
                # directives so the redirect-to-staging contract applies
                # to BOTH command-line flags AND in-script directives.
                # Otherwise the user could bypass via
                # `#SBATCH --output=/etc/foo` (which validate_sbatch_args
                # never sees). Only transforms when the feature is
                # enabled (bwrap/firejail) — landlock passes through.
                case "$flag_name" in
                    --output|--error|-o|-e)
                        if _slurm_output_feature_enabled; then
                            local _dval _new_val _new_line
                            case "$directive_body" in
                                --*=*) _dval="${directive_body#*=}" ;;
                                --*)   _dval="${directive_body#* }" ;;
                                *)     _dval="${directive_body:3}"  ;;  # -o val / -e val
                            esac
                            # Trim trailing whitespace / comments — defensive.
                            _dval="${_dval%%#*}"
                            _dval="${_dval%"${_dval##*[![:space:]]}"}"
                            _new_val="$(_transform_slurm_output_path "$_dval" "$project_dir")"
                            # Reconstruct as `#SBATCH <flag>=<new_val>` (canonical
                            # form). Quote the value via printf %q so a path with
                            # special characters cannot be interpreted by Slurm's
                            # directive tokenizer as a flag boundary (ASB-2026-001
                            # belt-and-braces — the whitespace-smuggling defense
                            # above already rejects the attack at the body level).
                            local _new_val_quoted
                            printf -v _new_val_quoted '%q' "$_new_val"
                            _new_line="#SBATCH $flag_name=$_new_val_quoted"
                            # Capture for env-var passing — last directive wins,
                            # matching command-line `validate_sbatch_args` semantics.
                            _capture_slurm_output_pair "$flag_name" "$_dval" "$_new_val"
                            safe_directives+="$_new_line"$'\n'
                        else
                            safe_directives+="$line"$'\n'
                        fi
                        ;;
                    *)
                        safe_directives+="$line"$'\n'
                        ;;
                esac
            else
                stripped_count=$((stripped_count + 1))
            fi
        fi
    done <<< "$script_content"

    if [[ "$stripped_count" -gt 0 ]]; then
        _sandbox_deny "stripped $stripped_count unsafe #SBATCH directive(s) from script (denied flags are not allowed in directives either)"
    fi

    # Default --output injection: extend the staging + symlink contract to
    # the implicit `slurm-<jobid>.out` path. Without this, an in-sandbox
    # agent could symlink-plant `<cwd>/slurm-<next-jobid>.out` before
    # slurmstepd opens it (cwd is sandbox-writable; the staging dir is not).
    #
    # Fires only when:
    #   - the feature is on (bwrap/firejail; landlock has no RO-overlay)
    #   - AND neither validate_sbatch_args (CLI -o/--output) nor the
    #     directive loop above (#SBATCH --output=) populated the capture.
    #
    # Pattern matches stock sbatch: `slurm-%A_%a.out` for arrays (detected
    # by --array in VALIDATED_ARGS), `slurm-%j.out` otherwise. Stderr is
    # untouched — stock Slurm merges stderr into stdout when only --output
    # is set, and the prelude's stderr-symlink branch no-ops on empty
    # capture, so no `--error` injection is needed.
    #
    # Injected as a #SBATCH directive (not a CLI flag) so the precedence
    # ordering stays correct: an explicit CLI flag on a future submission
    # would still win. Goes through `_capture_slurm_output_pair` so the
    # prelude built below picks up the same values it would for any other
    # captured path.
    if _slurm_output_feature_enabled && [[ -z "${_USER_SLURM_OUTPUT:-}" ]]; then
        local _default_pat _default_staging _is_array=false _va
        for _va in "${VALIDATED_ARGS[@]}"; do
            case "$_va" in
                -a|--array|--array=*) _is_array=true; break ;;
            esac
        done
        if $_is_array; then
            _default_pat="slurm-%A_%a.out"
        else
            _default_pat="slurm-%j.out"
        fi
        _default_staging="$(_transform_slurm_output_path "$_default_pat" "$project_dir")"
        _capture_slurm_output_pair "--output" "$_default_pat" "$_default_staging"
        safe_directives+="#SBATCH --output=$_default_staging"$'\n'
    fi

    # Strip #SBATCH directives from script body — they're in the wrapper header.
    local script_body
    script_body="$(printf '%s\n' "$script_content" | grep -vE '^[[:space:]]*#SBATCH' || true)"

    # Extract the interpreter from the shebang (default: sh, matching Slurm).
    # The wrapper pipes the script to the interpreter via stdin instead of
    # using `sh -c`, so the user's shebang is honored.  The #! line is a
    # comment in all common interpreters (sh, bash, python, perl, R, ruby),
    # so leaving it in the script content is harmless.
    local first_line interpreter
    first_line="$(head -1 <<< "$script_body")"
    if [[ "$first_line" == "#!"* ]]; then
        interpreter="${first_line#\#!}"
        interpreter="${interpreter# }"  # strip optional leading space
    else
        interpreter="/bin/sh"
    fi

    # Generate a unique EOF marker and verify it doesn't collide with
    # the script content.  This lets us inline the entire script via
    # heredoc — no temp files, no NFS issues, no cleanup needed.
    local eof_marker="_CHAPERON_EOF_${RANDOM}_${RANDOM}_$$"
    if printf '%s' "$script_body" | grep -qF "$eof_marker"; then
        _sandbox_warn "script contains the internal heredoc marker '$eof_marker'. This is astronomically unlikely — please resubmit."
        return 1
    fi

    # Build a self-contained wrapper:
    #   1. #SBATCH directives (validated)
    #   2. Inline script via heredoc
    #   3. Either pipe to an interpreter (shells) or materialise to a
    #      tmpfile inside the sandbox and exec it (non-shells) so the
    #      user's shebang is honored AND $1/$@/$# survive.
    #
    # Why two paths? Shells support `-s --` (read script from stdin, treat
    # following tokens as positional args), so for #!/bin/bash etc. we keep
    # the original pipe-through-stdin form and append `-s -- arg1 arg2 …`.
    # Non-shell interpreters (python, perl, R, …) either don't read stdin
    # the same way or set argv[0] to '-' / '-c' when they do, breaking
    # `sys.argv` for the user's script. For those we write the script to
    # a private tmpfs file inside the sandbox at runtime and exec it
    # directly with the positionals — argv ends up exactly as if the
    # script had been launched as a normal file.
    #
    # Pre-quote the script positional args once: bash printf %q produces
    # safe shell-quoted tokens that survive embedding in either generated
    # wrapper form below.
    local quoted_script_args=""
    if (( ${#script_args[@]} > 0 )); then
        local _sa
        for _sa in "${script_args[@]}"; do
            quoted_script_args+=" $(printf '%q' "$_sa")"
        done
    fi

    # Compose the in-sandbox prelude that creates relative symlinks
    # from the user's intended --output/--error paths to the
    # chaperon-managed staging files. The prelude runs INSIDE the
    # sandbox so its filesystem writes are governed by the bind-mount
    # envelope — that's what makes the user's path the permission
    # check (if intended is not sandbox-writable, the symlink fails
    # and the log only lives at the staging path, which is always
    # reachable for reading).
    #
    # Only emitted when the feature captured something (validate_sbatch_args
    # or the #SBATCH directive filter populated _STAGING_SLURM_*). Empty on
    # landlock because _slurm_output_feature_enabled gates the chaperon-side
    # transform — no captures, no prelude.
    local _slurm_link_prelude=""
    if [[ -n "${_STAGING_SLURM_OUTPUT:-}" || -n "${_STAGING_SLURM_ERROR:-}" ]]; then
        local _q_user_out _q_stage_out _q_user_err _q_stage_err
        printf -v _q_user_out  '%q' "${_USER_SLURM_OUTPUT:-}"
        printf -v _q_stage_out '%q' "${_STAGING_SLURM_OUTPUT:-}"
        printf -v _q_user_err  '%q' "${_USER_SLURM_ERROR:-}"
        printf -v _q_stage_err '%q' "${_STAGING_SLURM_ERROR:-}"
        # heredoc-style assembly — values pre-quoted via printf %q so they
        # survive embedding regardless of special chars. The prelude is
        # bash syntax; emitted only into bash contexts (see embedding
        # below — non-bash shell scripts skip it).
        _slurm_link_prelude="$(cat <<EOF
# --- agent-sandbox: link intended slurm output paths to staging ---
_sandbox_slurm_resolve_pat() {
    local _p="\$1"
    local _array_id="\${SLURM_ARRAY_JOB_ID:-\${SLURM_JOB_ID:-}}"
    _p="\${_p//%j/\${SLURM_JOB_ID:-}}"
    _p="\${_p//%A/\$_array_id}"
    _p="\${_p//%a/\${SLURM_ARRAY_TASK_ID:-}}"
    _p="\${_p//%N/\${SLURMD_NODENAME:-\${HOSTNAME:-}}}"
    _p="\${_p//%n/\${SLURM_NODEID:-0}}"
    _p="\${_p//%t/\${SLURM_PROCID:-0}}"
    _p="\${_p//%u/\${USER:-}}"
    _p="\${_p//%x/\${SLURM_JOB_NAME:-}}"
    printf '%s' "\$_p"
}
_sandbox_link_slurm_output() {
    local _stream="\$1" _intended_template="\$2" _staging_template="\$3"
    [[ -z "\$_intended_template" || -z "\$_staging_template" ]] && return 0
    local _intended _staging
    _intended="\$(_sandbox_slurm_resolve_pat "\$_intended_template")"
    _staging="\$(_sandbox_slurm_resolve_pat "\$_staging_template")"
    [[ "\$_intended" != /* ]] && _intended="\$PWD/\$_intended"
    [[ "\$_intended" == "\$_staging" ]] && return 0
    if ! mkdir -p -- "\$(dirname -- "\$_intended")" 2>/dev/null; then
        echo "sandbox: \$_stream-symlink: parent dir of '\$_intended' not sandbox-writable; log lives at \$_staging" >&2
        return 0
    fi
    local _rel
    _rel="\$(realpath --relative-to="\$(dirname -- "\$_intended")" "\$_staging" 2>/dev/null)" \\
        || _rel="\$_staging"
    rm -f -- "\$_intended" 2>/dev/null
    if ! ln -s -- "\$_rel" "\$_intended" 2>/dev/null; then
        echo "sandbox: \$_stream-symlink to '\$_intended' failed; log lives at \$_staging" >&2
    fi
}
_sandbox_link_slurm_output stdout $_q_user_out $_q_stage_out
_sandbox_link_slurm_output stderr $_q_user_err $_q_stage_err
unset -f _sandbox_link_slurm_output _sandbox_slurm_resolve_pat
# --- end agent-sandbox prelude ---
EOF
)"
    fi

    {
        printf '#!/bin/bash --\n'
        if [[ -n "$safe_directives" ]]; then
            printf '%s' "$safe_directives"
        fi
        printf '\n# --- Chaperon wrapper (auto-generated) ---\n'
        # Restore Slurm's submission cwd on the compute node before
        # exec'ing into the sandbox. Pairs with each backend's
        # `_resolve_inherited_cwd` chdir target: backends that *can*
        # enforce cwd via --chdir (bwrap, firejail) read $SLURM_SUBMIT_DIR
        # directly, so this `cd` is redundant for them. Backends that
        # cannot (landlock has no --chdir surface — it inherits cwd from
        # the parent) rely on this line to land in the submission dir on
        # clusters whose prolog drops cwd to $HOME. `:-.` no-ops cleanly
        # when SLURM_SUBMIT_DIR is unset; `|| true` swallows a stale dir.
        printf 'cd "${SLURM_SUBMIT_DIR:-.}" 2>/dev/null || true\n'
        printf '_SCRIPT=$(cat <<'"'"'%s'"'"'\n' "$eof_marker"
        printf '%s\n' "$script_body"
        printf '%s\n' "$eof_marker"
        printf ')\n'

        if _is_shell_interpreter "$interpreter"; then
            # Shell path: pipe script to `<interp> -s -- <args>` so the
            # shell reads the body from stdin and assigns positionals.
            #
            # Strip any standalone `--` tokens from the interpreter line —
            # `--` ends option processing, so a shebang like
            # `#!/bin/bash --` (or the stub's `--wrap` synthesis) would
            # produce `bash -- -s ...` and bash would treat `-s` as a
            # filename. We append our own `-s --` below, so a leading
            # `--` from the user is redundant anyway.
            local interp_clean=""
            local _interp_tokens _it
            # shellcheck disable=SC2206  # word-splitting on the shebang is intentional
            _interp_tokens=($interpreter)
            for _it in "${_interp_tokens[@]}"; do
                [[ "$_it" == "--" ]] && continue
                interp_clean+="${interp_clean:+ }$_it"
            done
            local sep=""
            if (( ${#script_args[@]} > 0 )); then
                sep=" --"
            fi
            # When the slurm-output prelude is non-empty (bwrap/firejail
            # + at least one --output/--error captured), wrap the
            # inside-sandbox invocation in `bash -c '<prelude>; exec
            # <interp> -s ...'`. The outer bash runs the prelude (which
            # creates the relative symlinks under sandbox bind-mount
            # control), then exec's the user's chosen shell with the
            # script piped to stdin still intact. Without the prelude
            # (landlock or no --output/--error), keep the original
            # direct-exec form.
            if [[ -n "$_slurm_link_prelude" ]]; then
                local _inside="$_slurm_link_prelude"$'\n'"exec $interp_clean -s$sep \"\$@\""
                local _inside_q="${_inside//\'/\'\\\'\'}"
                printf 'printf '"'"'%%s\\n'"'"' "$_SCRIPT" | exec %q --project-dir %q -- bash -c '"'"'%s'"'"' _chaperon%s\n' \
                    "$sandbox_exec" "$project_dir" "$_inside_q" "$quoted_script_args"
            else
                printf 'printf '"'"'%%s\\n'"'"' "$_SCRIPT" | exec %q --project-dir %q -- %s -s%s%s\n' \
                    "$sandbox_exec" "$project_dir" "$interp_clean" "$sep" "$quoted_script_args"
            fi
        else
            # Non-shell path: inside the sandbox, materialise the script to
            # a private tmpfs file, chmod +x, exec it with the positionals.
            # The sandbox's /tmp is per-invocation private (bwrap/firejail)
            # or at minimum user-owned (landlock) — the file is gone the
            # moment the sandbox tears down, and the rm -f is
            # belt-and-suspenders for landlock's shared-tmp case.
            #
            # The runner-script body uses double-quoted single-quotes-by-
            # concatenation so we don't have to fight nested quoting:
            # everything between '...' is literal except for double-quote
            # boundaries we open to embed a literal single quote ('"'"').
            #
            # The slurm-output prelude (when non-empty) goes BEFORE
            # `set -e` so a prelude failure doesn't abort the user's
            # script — graceful degradation: no symlink, log only at
            # the staging path.
            local runner=""
            [[ -n "$_slurm_link_prelude" ]] && runner="$_slurm_link_prelude"$'\n'
            runner+='set -e
_t=$(mktemp /tmp/.chaperon-script-XXXXXX) || exit 1
trap '"'"'rm -f "$_t"'"'"' EXIT
printf '"'"'%s\n'"'"' "$1" > "$_t"
chmod +x "$_t"
shift
"$_t" "$@"'
            # Wrap the runner in single quotes for the bash -c argument
            # by escaping any embedded single quotes ('\'').
            local runner_q="${runner//\'/\'\\\'\'}"
            printf 'exec %q --project-dir %q -- bash -c '"'"'%s'"'"' _chaperon_runner "$_SCRIPT"%s\n' \
                "$sandbox_exec" "$project_dir" "$runner_q" "$quoted_script_args"
        fi
    } > "$output_file"
    chmod +x "$output_file"
}

# Create a --wrap style wrapped command.
# Usage: create_wrapped_command <sandbox_exec> <project_dir> <wrap_cmd>
# Prints the --wrap argument value to stdout.
create_wrapped_command() {
    local sandbox_exec="$1" project_dir="$2" wrap_cmd="$3"
    printf '%q --project-dir %q -- sh -c %q' \
        "$sandbox_exec" "$project_dir" "$wrap_cmd"
}

# ── Job tagging via --comment (for scancel/squeue scoping) ───────
#
# Every job submitted through the chaperon gets a structured --comment
# tag that encodes the session and project identity.  This is queried
# by scancel/squeue to scope operations — no file-based tracking needed.
#
# Tag format:  chaperon:sid=<SESSION_ID>,proj=<PROJECT_HASH>[,user=<comment>]:END
#
#   sid  = unique per-chaperon-instance (PID + epoch, set once at startup)
#   proj = first 12 hex chars of md5(project_dir)
#   user = the user-supplied --comment value, if any (url-encoded to avoid commas)
#
# Query examples:
#   squeue --me -h -o "%i %k" | grep "chaperon:sid=$SID"   → session scope
#   squeue --me -h -o "%i %k" | grep "chaperon:.*proj=$H"  → project scope
#   squeue --me -h -o "%i %k" | grep "^chaperon:"          → user scope

# Session ID: unique per chaperon process.  Combine PID and epoch
# so that recycled PIDs from a later boot don't collide.
# Guard: only set once per chaperon process — _handler_lib.sh is
# re-sourced for each handler dispatch, but the session ID must remain
# stable across all requests within the same chaperon instance.
if [[ -z "${_CHAPERON_SESSION_ID:-}" ]]; then
    _CHAPERON_SESSION_ID="${BASHPID:-$$}.$(date +%s)"
fi

# Build the --comment value for sbatch.
# Usage: _build_chaperon_comment <project_dir>
# Reads _USER_COMMENT (set by validate_sbatch_args).
_build_chaperon_comment() {
    local project_dir="$1"
    local proj_hash
    proj_hash="$(printf '%s' "$project_dir" | md5sum | cut -c1-12)"

    local tag="chaperon:sid=${_CHAPERON_SESSION_ID},proj=${proj_hash}"

    # Append user's original comment with encoding to prevent scope pollution.
    # Encode commas (tag delimiter), colons (tag prefix), and equals signs
    # (key=value separator) to prevent crafted comments from injecting
    # fake chaperon:, sid=, or proj= patterns into the tag.
    if [[ -n "${_USER_COMMENT:-}" ]]; then
        local safe_comment="${_USER_COMMENT//,/%2C}"
        safe_comment="${safe_comment//:/%3A}"
        safe_comment="${safe_comment//=/%3D}"
        tag+=",user=${safe_comment}"
    fi

    # End marker — colons are percent-encoded in user values, so :END
    # is an unambiguous boundary for _strip_chaperon_tags().
    tag+=":END"

    printf '%s' "$tag"
}

# Query squeue (+ sacct fallback) for job IDs matching a chaperon tag pattern.
# Usage: _query_chaperon_jobs <grep_pattern>
# Prints matching job IDs (one per line).
_query_chaperon_jobs() {
    local pattern="$1"
    local _real_squeue="${REAL_SQUEUE:-/usr/bin/squeue}"
    local _real_sacct="${REAL_SACCT:-/usr/bin/sacct}"
    local _results

    # squeue: pending/running jobs
    _results="$(timeout 10 "$_real_squeue" --me -h -o "%i %k" 2>/dev/null \
        | grep -E "$pattern" \
        | awk '{print $1}')" || true

    # sacct fallback: recently completed jobs (if slurmdbd is available)
    if [[ -x "$_real_sacct" ]]; then
        local _sacct_results
        _sacct_results="$(timeout 10 "$_real_sacct" -u "$(id -un)" \
            -n -o "JobID%20,Comment%200" -X --starttime=now-7days 2>/dev/null \
            | grep -E "$pattern" \
            | awk '{print $1}')" || true
        if [[ -n "$_sacct_results" ]]; then
            _results="$(printf '%s\n%s' "${_results:-}" "$_sacct_results" | sort -u)"
        fi
    fi

    [[ -n "${_results:-}" ]] && printf '%s\n' "$_results"
}

# Get the set of job IDs that this scope allows.
# Prints job IDs one per line.
# Usage: _get_scoped_jobs <scope> <project_dir>
_get_scoped_jobs() {
    local scope="$1" project_dir="$2"

    case "$scope" in
        session)
            _query_chaperon_jobs "chaperon:sid=${_CHAPERON_SESSION_ID}[,.]"
            ;;
        project)
            local proj_hash
            proj_hash="$(printf '%s' "$project_dir" | md5sum | cut -c1-12)"
            _query_chaperon_jobs "chaperon:.*proj=${proj_hash}"
            ;;
        user|none)
            # Both "user" and "none" return ALL jobs of the current user
            local _real_squeue="${REAL_SQUEUE:-/usr/bin/squeue}"
            local _real_sacct="${REAL_SACCT:-/usr/bin/sacct}"
            local _user_jobs
            _user_jobs="$(timeout 10 "$_real_squeue" --me -h -o "%i" 2>/dev/null)" || true
            # sacct fallback for completed jobs
            if [[ -x "$_real_sacct" ]]; then
                local _sacct_jobs
                _sacct_jobs="$(timeout 10 "$_real_sacct" -u "$(id -un)" \
                    -n -o "JobID%20" -X --starttime=now-7days 2>/dev/null \
                    | awk '{print $1}')" || true
                [[ -n "$_sacct_jobs" ]] && _user_jobs="$(printf '%s\n%s' "${_user_jobs:-}" "$_sacct_jobs" | sort -u)"
            fi
            [[ -n "${_user_jobs:-}" ]] && printf '%s\n' "$_user_jobs"
            ;;
        *)
            _sandbox_warn "unknown SLURM_SCOPE value: '$scope'. Valid values: session, project, user, none"
            return 1
            ;;
    esac
}

# ── Query a job's comment (squeue → sacct fallback) ──────────────
# squeue only shows pending/running jobs. For recently completed jobs,
# fall back to sacct (which queries the persistent accounting database).
# Returns the comment string, or empty if the job is not found.
_get_job_comment() {
    local base_id="$1"
    local _real_squeue="${REAL_SQUEUE:-/usr/bin/squeue}"
    local _real_sacct="${REAL_SACCT:-/usr/bin/sacct}"
    local comment

    # Try squeue first (fast, no database dependency)
    comment="$(timeout 10 "$_real_squeue" -j "$base_id" --me -h -o "%k" 2>/dev/null)" || true
    if [[ -n "$comment" ]]; then
        printf '%s' "$comment"
        return 0
    fi

    # Fall back to sacct (persistent, survives job completion)
    if [[ -x "$_real_sacct" ]]; then
        comment="$(timeout 10 "$_real_sacct" -j "$base_id" -u "$(id -un)" \
            -n -o "Comment%200" -X --starttime=now-7days 2>/dev/null \
            | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
            | head -1)" || true
        if [[ -n "$comment" ]]; then
            printf '%s' "$comment"
            return 0
        fi
    fi

    return 1
}

# ── Validate that a single job ID is in scope ────────────────────
# Uses a targeted squeue query (with sacct fallback) instead of
# fetching all scoped jobs.
# Shared by scontrol, sstat, and any handler that needs per-job validation.
_validate_job_in_scope() {
    local job_id="$1" scope="$2" project_dir="$3"

    local base_id="${job_id%%_*}"
    if [[ ! "$base_id" =~ ^[0-9]+$ ]]; then
        _sandbox_warn "'$job_id' is not a valid job ID."
        return 1
    fi

    # Query the job's comment (squeue first, sacct fallback)
    local comment
    comment="$(_get_job_comment "$base_id")" || true

    if [[ -z "$comment" ]]; then
        _sandbox_warn "job $job_id not found in queue or not owned by you."
        return 1
    fi

    # Check if the comment matches the scope.
    # Strip :END marker, then match only in the tag prefix (before ,user=)
    # to prevent injection via crafted user comments. Require delimiter
    # after session ID to prevent prefix collisions (sid=123.100 vs
    # sid=123.1000000).
    local match=false
    local stripped="${comment%:END}"
    local tag_prefix="${stripped%%,user=*}"
    case "$scope" in
        session)
            [[ "$tag_prefix" == "chaperon:sid=${_CHAPERON_SESSION_ID},"* || \
               "$tag_prefix" == "chaperon:sid=${_CHAPERON_SESSION_ID}" ]] && match=true
            ;;
        project)
            local proj_hash
            proj_hash="$(printf '%s' "$project_dir" | md5sum | cut -c1-12)"
            [[ "$tag_prefix" == *"proj=${proj_hash}"* ]] && match=true
            ;;
        user)
            # "user" allows any job owned by this user (squeue --me already filters by uid)
            match=true
            ;;
        none)
            # No scope restriction
            match=true
            ;;
    esac

    if ! "$match"; then
        _sandbox_deny "job $job_id was not submitted by this $scope — cannot modify."
        return 1
    fi
    return 0
}

# ── Strip chaperon tags from Slurm output ──────────────────────────
#
# Pipe Slurm command output through this to replace chaperon tags with
# the user's original comment (or empty string if none was set).
#
# Tag format: chaperon:sid=...,proj=...[,user=<percent-encoded>]:END
# The :END marker is an unambiguous boundary because colons are
# percent-encoded (%3A) in the user value, so ":END" cannot appear
# inside it.  The user= value also has commas (%2C) and equals (%3D)
# percent-encoded.  We decode these after extracting.
#
# Usage: "$real_squeue" ... | _strip_chaperon_tags
_strip_chaperon_tags() {
    # Step 1: Replace full tag with just the user comment value (or empty).
    #   - With user comment:  chaperon:sid=X,proj=Y,user=VALUE:END → VALUE
    #   - Without:            chaperon:sid=X,proj=Y:END             → (empty)
    #   The :END marker provides an unambiguous boundary regardless of
    #   output format (tabular, JSON, YAML, scontrol key=value).
    # Step 2: Decode the three percent-encoded characters.
    sed -E 's/chaperon:sid=[^,]*,proj=[^,]*(,user=([^:]*))?\:END/\2/g' \
    | sed \
        -e 's/%2C/,/g' \
        -e 's/%3A/:/g' \
        -e 's/%3D/=/g'
}
