Files
gstack/bin/gstack-egress-lib.sh
garrytan cd7485b0b4 W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design,
ios-qa daemon, lib and scripts resolve the state root through
bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the
twin and stop with a reinstall message when it is missing; hooks source it
and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts
delegate to resolveStateRoot. Analytics writers and readers move together
so the usage log stays one file. gstack-uninstall deletes state only at
~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor,
the checkout or the git root, and leaves any other resolved root in place
with the removal command. Fixtures that copy single bins now copy the twin.
2026-09-30 06:38:50 +00:00

119 lines
5.1 KiB
Bash

# 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 <closed|open> <sink> <host> <class> <consent> \
# (<payload-file>|--no-payload) <curl-cmd> [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 <closed|open> <sink> <host> <class> <consent> <git-cmd> [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 without dirname (builtins only), so the lib works even under
# a stripped test PATH.
case "${BASH_SOURCE[0]}" in
*/*) _gstack_egress_lib_dir="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" ;;
*) _gstack_egress_lib_dir="$(pwd)" ;;
esac
# 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' "<gstack state root: $_gstack_egress_lib_dir/gstack-state-root.sh is missing; reinstall with ./setup or /gstack-upgrade>"
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 "$@"
}