Files
gstack/bin/gstack-egress-lib.sh
T
Garry Tan 97ace4a452 feat(security): receipt admin scripts + user git-ops (zero exceptions)
Wire the remaining shell egress through gstack-egress-lib.sh:

- gstack-gbrain-mcp-verify: both JSON-RPC probe POSTs (initialize +
  tools/list) receipted fail-closed via payload files (hash == wire
  bytes). A refused receipt lands in the NETWORK class — no send.
- gstack-security-dashboard / gstack-community-dashboard: the
  community-pulse GETs receipted fail-open (read-only stats must not
  break over an audit hiccup).
- gstack-gbrain-supabase-provision: api_call receipted fail-closed.
  Each retry attempt hands the helper a fresh copy of the body file
  (the helper consumes its payload). The receipt hashes the request
  body only — the PAT never reaches the ledger or any log. Refusal
  exits 8 without retrying.
- git-class sha256:null receipts, fail-open: gstack-artifacts-init
  (ls-remote, initial push, fetch/pull recovery, retry push),
  gstack-brain-restore (staging clone, existing-repo fetch),
  gstack-session-update (self-update pull).

gstack-team-init needs no wiring: every git clone in it is inside an
echoed instruction string, not an executed command.

The lib now self-locates with shell builtins only (no dirname), so
sourcing works under the whitelist-PATH test harnesses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b8c5e2055b21ab72878b3e46f8047782ee65a11c)
2026-08-12 15:31:48 -07:00

119 lines
4.8 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
_gstack_egress_home() {
if [ -n "${GSTACK_HOME:-}" ]; then
printf '%s' "$GSTACK_HOME"
elif [ -n "${GSTACK_STATE_DIR:-}" ]; then
printf '%s' "$GSTACK_STATE_DIR"
else
printf '%s' "$HOME/.gstack"
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 "$@"
}