mirror of https://github.com/garrytan/gstack.git
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)
This commit is contained in:
parent
52288947ec
commit
97ace4a452
|
|
@ -40,6 +40,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||
URL_BIN="$SCRIPT_DIR/gstack-artifacts-url"
|
||||
REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"
|
||||
|
||||
# Egress receipt helpers (_receipted_git): fail-open for user-directed
|
||||
# git ops against the user's own artifacts remote.
|
||||
. "$SCRIPT_DIR/gstack-egress-lib.sh"
|
||||
|
||||
# remote host for receipt records (github.com etc). Set once PUSH_URL exists.
|
||||
_artifacts_host() {
|
||||
local h="${PUSH_URL#*://}"; h="${h#*@}"; h="${h%%[/:]*}"
|
||||
echo "${h:-unknown}"
|
||||
}
|
||||
|
||||
REMOTE_URL=""
|
||||
HOST_PREF=""
|
||||
URL_FORM_SUPPORTED="false"
|
||||
|
|
@ -185,7 +195,8 @@ PUSH_URL=$("$URL_BIN" --to ssh "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICA
|
|||
|
||||
# ---- verify push URL is reachable ----
|
||||
echo "Verifying remote connectivity: $PUSH_URL"
|
||||
if ! git ls-remote "$PUSH_URL" >/dev/null 2>&1; then
|
||||
if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-remote-ls-remote "user ran gstack-artifacts-init" \
|
||||
bash -c 'git ls-remote "$1" >/dev/null 2>&1' _ "$PUSH_URL"; then
|
||||
cat >&2 <<EOF
|
||||
Remote not reachable via SSH: $PUSH_URL
|
||||
This could mean:
|
||||
|
|
@ -346,10 +357,15 @@ else
|
|||
fi
|
||||
|
||||
# ---- initial push ----
|
||||
if ! git push -q -u origin main 2>/dev/null; then
|
||||
if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-initial-push "user ran gstack-artifacts-init" \
|
||||
bash -c 'git push -q -u origin main 2>/dev/null'; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if git fetch origin 2>/dev/null && git pull --ff-only origin "$CURRENT_BRANCH" 2>/dev/null; then
|
||||
git push -q -u origin "$CURRENT_BRANCH" || {
|
||||
if _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-fetch "user ran gstack-artifacts-init" \
|
||||
bash -c 'git fetch origin 2>/dev/null' \
|
||||
&& _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-pull "user ran gstack-artifacts-init" \
|
||||
bash -c 'git pull --ff-only origin "$1" 2>/dev/null' _ "$CURRENT_BRANCH"; then
|
||||
_receipted_git open artifacts-init "$(_artifacts_host)" artifacts-initial-push "user ran gstack-artifacts-init" \
|
||||
git push -q -u origin "$CURRENT_BRANCH" || {
|
||||
echo "Push to $PUSH_URL failed. The remote may have divergent content." >&2
|
||||
echo "Try: cd ~/.gstack && git pull --rebase origin $CURRENT_BRANCH && git push origin $CURRENT_BRANCH" >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ set -euo pipefail
|
|||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
|
||||
|
||||
# Egress receipt helpers (_receipted_git): fail-open for user-directed
|
||||
# git ops against the user's own artifacts remote.
|
||||
. "$SCRIPT_DIR/gstack-egress-lib.sh"
|
||||
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during the
|
||||
# migration window. The migration script renames the file in place.
|
||||
if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
|
||||
|
|
@ -78,7 +82,9 @@ STAGING=$(mktemp -d "${TMPDIR:-/tmp}/gstack-brain-restore.XXXXXX")
|
|||
trap 'rm -rf "$STAGING" 2>/dev/null' EXIT
|
||||
|
||||
echo "Cloning $REMOTE_URL to staging..."
|
||||
if ! git clone --quiet "$REMOTE_URL" "$STAGING/repo" 2>/dev/null; then
|
||||
RESTORE_HOST="${REMOTE_URL#*://}"; RESTORE_HOST="${RESTORE_HOST#*@}"; RESTORE_HOST="${RESTORE_HOST%%[/:]*}"
|
||||
if ! _receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-clone "user ran gstack-brain-restore" \
|
||||
bash -c 'git clone --quiet "$1" "$2" 2>/dev/null' _ "$REMOTE_URL" "$STAGING/repo"; then
|
||||
echo "Clone failed. Check:" >&2
|
||||
echo " - URL is correct: $REMOTE_URL" >&2
|
||||
echo " - Auth: gh auth status (github) / glab auth status (gitlab)" >&2
|
||||
|
|
@ -160,7 +166,8 @@ done
|
|||
# ---- move .git into place ----
|
||||
if [ -d "$GSTACK_HOME/.git" ]; then
|
||||
# Existing .git with matching remote — just fetch + fast-forward.
|
||||
git -C "$GSTACK_HOME" fetch origin >/dev/null 2>&1 || true
|
||||
_receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-fetch "user ran gstack-brain-restore" \
|
||||
bash -c 'git -C "$1" fetch origin >/dev/null 2>&1' _ "$GSTACK_HOME" || true
|
||||
else
|
||||
mv "$STAGING/repo/.git" "$GSTACK_HOME/.git"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ set -uo pipefail
|
|||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
|
||||
# Egress receipt helpers (_receipted_curl): fail-open for read-only stats.
|
||||
. "$GSTACK_DIR/bin/gstack-egress-lib.sh"
|
||||
|
||||
# Source Supabase config if not overridden by env
|
||||
if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
|
||||
. "$GSTACK_DIR/supabase/config.sh"
|
||||
|
|
@ -35,10 +38,11 @@ fi
|
|||
# never as a healthy "Weekly active installs: 0".
|
||||
TMPBODY="$(mktemp)"
|
||||
trap 'rm -f "$TMPBODY"' EXIT
|
||||
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||
SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}"
|
||||
HTTP_CODE="$(_receipted_curl open community-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \
|
||||
curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
||||
-H "apikey: ${ANON_KEY}" \
|
||||
2>/dev/null || true)"
|
||||
-H "apikey: ${ANON_KEY}" || true)"
|
||||
# curl prints its own 000 before a non-zero exit — a `|| echo` here would
|
||||
# double it to "000000" in user-facing output. Normalize to the last 3 chars.
|
||||
HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@
|
|||
# their own EXIT traps and a trap set by a sourced library would clobber
|
||||
# the caller's. All temp handling is immediate, per call.
|
||||
|
||||
_gstack_egress_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ URL="$1"
|
|||
command -v curl >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: curl is required" >&2; exit 2; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: jq is required (brew install jq)" >&2; exit 2; }
|
||||
|
||||
# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed.
|
||||
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
|
||||
MCP_HOST=$(echo "$URL" | sed -E 's|^[a-z]+://([^/]+).*|\1|')
|
||||
|
||||
emit() {
|
||||
# emit <status> <server_name> <server_version> <error_class> <error_text> <url_supported> <raw_body>
|
||||
jq -n \
|
||||
|
|
@ -73,15 +77,20 @@ INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVers
|
|||
TMPBODY=$(mktemp -t gstack-mcp-verify.XXXXXX)
|
||||
trap 'rm -f "$TMPBODY"' EXIT
|
||||
|
||||
# Receipted fail-closed: the payload file is hashed and handed to curl as
|
||||
# the exact wire bytes. A refused receipt never hits the network — it lands
|
||||
# in the NETWORK class below (curl never ran, no HTTP code).
|
||||
INIT_PAYLOAD=$(mktemp -t gstack-mcp-init.XXXXXX)
|
||||
printf '%s' "$INIT_BODY" > "$INIT_PAYLOAD"
|
||||
set +e
|
||||
HTTP_CODE=$(curl -s -o "$TMPBODY" -w '%{http_code}' \
|
||||
HTTP_CODE=$(_receipted_curl closed gbrain-mcp-verify "$MCP_HOST" mcp-initialize-probe "user-invoked mcp verify" "$INIT_PAYLOAD" \
|
||||
curl -s -o "$TMPBODY" -w '%{http_code}' \
|
||||
--max-time 10 \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
|
||||
-d "$INIT_BODY" \
|
||||
"$URL" 2>/dev/null)
|
||||
"$URL")
|
||||
CURL_EXIT=$?
|
||||
set -e
|
||||
|
||||
|
|
@ -150,14 +159,18 @@ URL_SUPPORTED=false
|
|||
TOOLS_BODY_FILE=$(mktemp -t gstack-mcp-tools.XXXXXX)
|
||||
TOOLS_REQ='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
|
||||
|
||||
# Receipted fail-closed like the initialize probe. A refused receipt skips
|
||||
# the probe (nonzero TOOLS_EXIT) and the field stays false — best-effort.
|
||||
TOOLS_PAYLOAD=$(mktemp -t gstack-mcp-tools-req.XXXXXX)
|
||||
printf '%s' "$TOOLS_REQ" > "$TOOLS_PAYLOAD"
|
||||
set +e
|
||||
curl -s -o "$TOOLS_BODY_FILE" \
|
||||
_receipted_curl closed gbrain-mcp-verify "$MCP_HOST" mcp-tools-list-probe "user-invoked mcp verify" "$TOOLS_PAYLOAD" \
|
||||
curl -s -o "$TOOLS_BODY_FILE" \
|
||||
--max-time 10 \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
|
||||
-d "$TOOLS_REQ" \
|
||||
"$URL" >/dev/null 2>&1
|
||||
TOOLS_EXIT=$?
|
||||
set -e
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ set -euo pipefail
|
|||
|
||||
SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}"
|
||||
API_VERSION="v1"
|
||||
|
||||
# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed.
|
||||
# The receipt hashes the request body only — the PAT (Authorization header)
|
||||
# is never receipted or logged.
|
||||
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
|
||||
SUPABASE_API_HOST="${SUPABASE_API_BASE#*://}"; SUPABASE_API_HOST="${SUPABASE_API_HOST%%/*}"
|
||||
DEFAULT_WAIT_TIMEOUT=180
|
||||
POLL_INTERVAL=5
|
||||
CURL_TIMEOUT=30
|
||||
|
|
@ -135,11 +141,24 @@ api_call() {
|
|||
-H "Content-Type: application/json"
|
||||
-H "User-Agent: gstack-gbrain-supabase-provision"
|
||||
)
|
||||
# Receipted fail-closed. The retry loop reuses $body_file across
|
||||
# attempts, but the helper consumes its payload file — so each attempt
|
||||
# hands it a fresh copy (hash still equals the exact wire bytes; the
|
||||
# helper appends --data-binary @copy). Bodyless calls use --no-payload.
|
||||
local payload_arg="--no-payload"
|
||||
if [ -n "$body_file" ]; then
|
||||
curl_args+=(--data-binary "@$body_file")
|
||||
payload_arg=$(mktemp)
|
||||
cp "$body_file" "$payload_arg"
|
||||
fi
|
||||
local status
|
||||
if ! status=$(curl "${curl_args[@]}" "$url" 2>/dev/null); then
|
||||
local status rc=0
|
||||
status=$(_receipted_curl closed supabase-provision "$SUPABASE_API_HOST" "provision-api-call ($method $apipath)" "user ran gstack-gbrain-supabase-provision" "$payload_arg" \
|
||||
curl "${curl_args[@]}" "$url") || rc=$?
|
||||
if [ "$rc" -eq 3 ] && [ -z "$status" ]; then
|
||||
# Egress receipt refused — the send never happened (the helper's
|
||||
# problem/cause/fix message is already on stderr). Don't retry.
|
||||
exit 8
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
# curl itself failed (network, timeout, etc.). Retry.
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
die_net "network failure calling $method $apipath after $attempt attempts"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ set -uo pipefail
|
|||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
|
||||
# Egress receipt helpers (_receipted_curl): fail-open for read-only stats.
|
||||
. "$GSTACK_DIR/bin/gstack-egress-lib.sh"
|
||||
|
||||
# Source Supabase config
|
||||
if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
|
||||
. "$GSTACK_DIR/supabase/config.sh"
|
||||
|
|
@ -46,10 +49,11 @@ fi
|
|||
# surface are indistinguishable from good news.
|
||||
TMPBODY="$(mktemp)"
|
||||
trap 'rm -f "$TMPBODY"' EXIT
|
||||
HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||
SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}"
|
||||
HTTP_CODE="$(_receipted_curl open security-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \
|
||||
curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \
|
||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
||||
-H "apikey: ${ANON_KEY}" \
|
||||
2>/dev/null || true)"
|
||||
-H "apikey: ${ANON_KEY}" || true)"
|
||||
# curl prints its own 000 before a non-zero exit — a `|| echo` here would
|
||||
# double it to "000000" in user-facing output. Normalize to the last 3 chars.
|
||||
HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ set +e
|
|||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
|
||||
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
|
||||
|
||||
# Egress receipt helpers (_receipted_git): fail-open — an update pull must
|
||||
# never block a session over a receipt hiccup.
|
||||
. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh"
|
||||
THROTTLE_FILE="$STATE_DIR/.last-session-update"
|
||||
LOCK_DIR="$STATE_DIR/.setup-lock"
|
||||
LOG_FILE="$STATE_DIR/analytics/session-update.log"
|
||||
|
|
@ -76,7 +80,10 @@ fi
|
|||
|
||||
# ── Pull latest ──
|
||||
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
git -C "$GSTACK_DIR" pull --ff-only -q 2>/dev/null
|
||||
UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "")
|
||||
UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}"
|
||||
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
|
||||
bash -c 'git -C "$1" pull --ff-only -q 2>/dev/null' _ "$GSTACK_DIR"
|
||||
PULL_EXIT=$?
|
||||
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
|
||||
|
|
|
|||
|
|
@ -345,7 +345,10 @@ describe('gstack-brain-sync egress receipt gate', () => {
|
|||
const commitsBefore = git(['rev-list', '--count', 'HEAD']).stdout.trim();
|
||||
|
||||
// Make the receipt unwritable: security dir exists but is read-only.
|
||||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true, mode: 0o500 });
|
||||
// (artifacts-init may have created it already — mkdirSync's mode is a
|
||||
// no-op on an existing dir, so chmod explicitly.)
|
||||
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
|
||||
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
|
||||
try {
|
||||
const refused = run(['gstack-brain-sync', '--once']);
|
||||
expect(refused.status).toBe(1);
|
||||
|
|
|
|||
|
|
@ -51,13 +51,20 @@ function makeFakeCurl(opts: {
|
|||
printf 'CURL_CALL '"'"'%s'"'"' ' "$@" >> "${curlCallLog}"
|
||||
echo "" >> "${curlCallLog}"
|
||||
|
||||
# Walk argv to find -o <out> and -d <data>.
|
||||
# Walk argv to find -o <out> and the request body (-d <data> or the
|
||||
# receipted --data-binary @<payload-file> shape).
|
||||
out=""
|
||||
data=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) out="$2"; shift 2 ;;
|
||||
-d) data="$2"; shift 2 ;;
|
||||
--data-binary)
|
||||
case "$2" in
|
||||
@*) data="$(cat "\${2#@}" 2>/dev/null)" ;;
|
||||
*) data="$2" ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -83,6 +90,8 @@ function runVerify(token: string, url: string): { code: number; stdout: string;
|
|||
...process.env,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH}`,
|
||||
GBRAIN_MCP_TOKEN: token,
|
||||
// The probe writes egress receipts — keep them in the temp home.
|
||||
GSTACK_HOME: tmpDir,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue