# gstack-egress-lib.sh — shared egress-receipt helpers for bash sinks. # # This file is NOT executable; source it: # # . "$(dirname "$0")/gstack-egress-lib.sh" # # THREAT MODEL: the egress ledger is forensic observability — it records # ATTEMPTED egress so accidents are auditable; it is not an exfiltration # control. Receipts are written before send, outcomes are best-effort, and # fail-open callers can send unrecorded with a warning. # # Provides: # _receipted_curl \ # (|--no-payload) [args...] # — Writes an egress receipt BEFORE running the wrapped curl command. # When a payload file is given, the receipt hashes that file and curl # receives THE SAME FILE via an appended `--data-binary @file`, so the # recorded sha256 matches the exact wire bytes (scan-at-sink precedent). # The payload file is CONSUMED: the helper deletes it after the send # (and on refusal) — callers need no cleanup trap for it. # stdout is the wrapped command's stdout; returns its exit code. # A best-effort outcome record (`exit:N`) is appended after the send; # the receipt id is exported as _GSTACK_EGRESS_LAST_RECEIPT so callers # can append a more specific outcome (e.g. the HTTP status). # # Fail policy (first argument, per call): # closed — receipt failure REFUSES the send: nothing hits the network, # return 3, problem/cause/fix message on stderr. # open — receipt failure warns on stderr and proceeds unrecorded. # # _receipted_git [args...] # — Same contract for git-class ops: sha256:null receipt (a subprocess # owns the bytes), no payload file, command runs unmodified. # # NO EXIT traps in this file, ever: callers (gstack-telemetry-sync) own # their own EXIT traps and a trap set by a sourced library would clobber # the caller's. All temp handling is immediate, per call. # Self-locate with builtins only (no dirname), so the lib works under a # stripped test PATH and under zsh, where it used to resolve to the caller's # cwd and look for its siblings in the user's project. _gstack_helper=gstack-egress-lib.sh # === gstack self-locate (shared, byte-identical in every sourced helper) === # Skill blocks source helpers into the host's shell: bash, or zsh on macOS # (Claude Code's Bash tool included). Resolved once at source time, using # parameter expansion only: BASH_SOURCE under bash, %x under zsh (inside eval # so bash never parses it), made absolute with $PWD; else $GSTACK_ROOT/bin # when it holds this helper. Any other case, and any other shell, fails loudly # instead of guessing; the message and the return come before any bash- or # zsh-only syntax, so dash and sh print it instead of a syntax error. _gstack_helper_dir="" _gstack_helper_shell="" if [ -n "${BASH_VERSION:-}" ]; then _gstack_helper_shell=bash _gstack_helper_dir="${BASH_SOURCE[0]}" elif [ -n "${ZSH_VERSION:-}" ]; then _gstack_helper_shell=zsh eval '_gstack_helper_dir="${(%):-%x}"' fi case "$_gstack_helper_dir" in /*) _gstack_helper_dir="${_gstack_helper_dir%/*}" ;; */*) _gstack_helper_dir="$PWD/${_gstack_helper_dir%/*}" ;; ?*) _gstack_helper_dir="$PWD" ;; esac if [ -n "$_gstack_helper_shell" ] && [ ! -f "$_gstack_helper_dir/$_gstack_helper" ]; then _gstack_helper_dir="" if [ -n "${GSTACK_ROOT:-}" ] && [ -f "$GSTACK_ROOT/bin/$_gstack_helper" ]; then _gstack_helper_dir="$GSTACK_ROOT/bin" fi fi if [ -z "$_gstack_helper_shell" ] || [ -z "$_gstack_helper_dir" ]; then _gstack_helper_error="gstack: cannot locate $_gstack_helper (shell: ${_gstack_helper_shell:-${0##*/}}). Source it from bash or zsh, or export GSTACK_ROOT=. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location" printf '%s\n' "$_gstack_helper_error" >&2 return 1 2>/dev/null || exit 1 fi # === end gstack self-locate === _gstack_egress_lib_dir="$_gstack_helper_dir" unset _gstack_helper _gstack_helper_dir _gstack_helper_shell # State root from the shared twin (bin/gstack-state-root.sh, builtins only), # sourced lazily on first use. _gstack_egress_home() { if command -v gstack_state_root >/dev/null 2>&1 || { [ -f "$_gstack_egress_lib_dir/gstack-state-root.sh" ] && . "$_gstack_egress_lib_dir/gstack-state-root.sh"; }; then gstack_state_root else printf '%s' "" fi } # Problem + cause + fix, in plain language (DX contract for every # fail-closed refusal). $1 = sink, $2 = cause text from the receipt bridge. _gstack_egress_refusal() { local home home="$(_gstack_egress_home)" echo "gstack: $1 NOT sent — the egress receipt could not be written (${2:-unknown cause}). Fix: chmod -R u+w $home/security (or check GSTACK_HOME). What this is: gstack records everything it ATTEMPTS to send off-machine; see gstack-egress." >&2 } # Shared core. $6 is a payload file path or --no-payload; the rest is the # command to run. Payload files are deleted here (immediate, no traps). _gstack_egress_run() { local policy="$1" sink="$2" host="$3" class="$4" consent="$5" payload="$6" shift 6 local bin="$_gstack_egress_lib_dir/gstack-egress-receipt" local payload_flag=(--no-payload) [ "$payload" != "--no-payload" ] && payload_flag=(--payload-file "$payload") local receipt_id="" receipt_err="" err_file="" err_file="$(mktemp "${TMPDIR:-/tmp}/gstack-egress-err-XXXXXX" 2>/dev/null)" || err_file="" if [ -n "$err_file" ]; then receipt_id="$("$bin" write --sink "$sink" --host "$host" --class "$class" \ "${payload_flag[@]}" --consent "$consent" 2>"$err_file")" || receipt_id="" receipt_err="$(head -c 500 "$err_file" 2>/dev/null | tr '\n' ' ')" rm -f "$err_file" else receipt_id="$("$bin" write --sink "$sink" --host "$host" --class "$class" \ "${payload_flag[@]}" --consent "$consent" 2>/dev/null)" || receipt_id="" fi _GSTACK_EGRESS_LAST_RECEIPT="$receipt_id" if [ -z "$receipt_id" ]; then if [ "$policy" = "closed" ]; then _gstack_egress_refusal "$sink" "$receipt_err" [ "$payload" != "--no-payload" ] && rm -f "$payload" return 3 fi echo "gstack: egress receipt could not be written for $sink (${receipt_err:-unknown cause}) — sending anyway (fail-open). gstack normally records everything it ATTEMPTS to send off-machine; see gstack-egress." >&2 fi local status=0 if [ "$payload" = "--no-payload" ]; then "$@" || status=$? else "$@" --data-binary @"$payload" || status=$? rm -f "$payload" fi if [ -n "$receipt_id" ]; then "$bin" outcome "$receipt_id" "exit:$status" 2>/dev/null || true fi return $status } _receipted_curl() { _gstack_egress_run "$@" } _receipted_git() { local policy="$1" sink="$2" host="$3" class="$4" consent="$5" shift 5 _gstack_egress_run "$policy" "$sink" "$host" "$class" "$consent" --no-payload "$@" }