Files
gstack/bin/gstack-gbrain-repo-policy
garrytan 496c29c938 W1: privacy keys and trust-policy deny tiers merge across state roots; gstack-config reporting; test hermeticity
readConfigKey / gstack_read_config_key return the most restrictive
telemetry, memorable_recall, codex_reviews and update_check across the
resolved root and ~/.gstack; other keys read the resolved root only.
gstack-config set reports an overriding root with the exact override
command, list shows the winning root and a root-variable disagreement line.
gstack-gbrain-repo-policy get merges deny/read-only tiers. gstack-egress
reads through readConfigKey. test-setup.ts strips inherited
GSTACK_STATE_ROOT/GSTACK_STATE_DIR and redirects the legacy root.
2026-09-30 06:38:50 +00:00

331 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-gbrain-repo-policy — per-remote trust tier for gbrain repo ingest.
#
# Usage:
# gstack-gbrain-repo-policy get [<remote-url>]
# Print the tier for the given remote, or the current repo's origin
# if no URL is passed. Exits 0 with one of: read-write, read-only,
# deny, unset.
#
# gstack-gbrain-repo-policy get --batch
# Read remote URLs from stdin (one per line); print one tier per line
# in input order: read-write, read-only, deny, or none (no entry / no
# store). A corrupt store is a hard error (exit 2), NEVER quarantined:
# batch callers are unattended ingest gates that must fail closed
# rather than bypass a set policy.
#
# gstack-gbrain-repo-policy set <remote-url> <read-write|read-only|deny>
# Persist a tier for the given remote. Exits 0 on success.
#
# gstack-gbrain-repo-policy list
# Print every entry as "<key>\t<tier>", sorted by key.
#
# gstack-gbrain-repo-policy normalize <url>
# Print the normalized (canonical) key for a given remote URL.
# Use this when other skills or tests need the same collapsing logic.
#
# gstack-gbrain-repo-policy --help
#
# Storage:
# ~/.gstack/gbrain-repo-policy.json, mode 0600.
#
# File format:
# {
# "_schema_version": 2,
# "github.com/foo/bar": "read-write",
# "github.com/baz/qux": "deny"
# }
#
# Tier semantics:
# read-write — agent may search AND write new pages from this repo.
# read-only — agent may search but NEVER write pages from this repo.
# (Enforced at the caller level; this binary just stores the
# decision.)
# deny — no gbrain interaction at all.
#
# Legacy migration:
# On any read of a file missing `_schema_version` (or with version < 2),
# legacy `allow` values are atomically rewritten to `read-write`, and
# `_schema_version: 2` is added. Log line emitted on stderr when the
# migration actually changes anything. Idempotent: running twice is safe.
#
# Env:
# GSTACK_HOME — override the state root (the shared chain in
# bin/gstack-state-root.sh; used heavily in tests).
#
# Deny tiers merge across state roots: when the resolved root is not
# ~/.gstack, `get` also reads ~/.gstack/gbrain-repo-policy.json and returns
# its deny or read-only tier when that is more restrictive. Nothing is written
# there. Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
set -euo pipefail
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
POLICY_FILE="$STATE_DIR/gbrain-repo-policy.json"
gstack_legacy_root_select
LEGACY_POLICY_FILE=""
[ -n "$_gstack_legacy_root" ] && [ -f "$_gstack_legacy_root/gbrain-repo-policy.json" ] && LEGACY_POLICY_FILE="$_gstack_legacy_root/gbrain-repo-policy.json"
SCHEMA_VERSION=2
die() { echo "gstack-gbrain-repo-policy: $*" >&2; exit 2; }
require_jq() {
if ! command -v jq >/dev/null 2>&1; then
die "jq is required. Install with: brew install jq"
fi
}
# normalize <url> — canonical form: lowercase host + path, no protocol,
# no userinfo, no trailing .git or /. SSH shorthand (git@host:path) collapses
# to the same key as https://host/path.
normalize() {
local url="$1"
[ -z "$url" ] && { echo ""; return 0; }
# Strip protocol://
url="${url#*://}"
# Strip userinfo (git@, user:password@, etc.) — everything up to and
# including the first @ iff an @ appears before the first / or :.
case "$url" in
*@*)
local before_at="${url%%@*}"
case "$before_at" in
*/*|*:*) : ;; # @ is in the path, not userinfo — leave it
*) url="${url#*@}" ;;
esac
;;
esac
# SSH shorthand: github.com:foo/bar → github.com/foo/bar. Only when the
# hostname-part (before first /) contains a colon. sed is clearer than
# bash's `${var/:/\/}` which has tricky escaping.
local head="${url%%/*}"
case "$head" in
*:*) url=$(printf '%s' "$url" | sed 's|:|/|') ;;
esac
# Lowercase BEFORE the suffix strips so a `.GIT` suffix still strips —
# parity with lib/gstack-memory-helpers' canonicalizeRemote, which strips
# `.git` case-insensitively. GitHub and most hosts are case-insensitive on
# paths anyway; collapsing avoids duplicate entries for "Foo/Bar" vs
# "foo/bar". (Parity is pinned by test/gbrain-repo-policy-client.test.ts:
# a key set through THIS normalize must be found via the canonicalized form
# memory-ingest passes to `get --batch`.)
url=$(printf '%s' "$url" | tr '[:upper:]' '[:lower:]')
# Strip trailing slash(es) FIRST, so ".git/" still loses its suffix (same
# order as canonicalizeRemote — slash-first, then .git, then re-strip).
while [ "${url%/}" != "$url" ]; do url="${url%/}"; done
# Strip trailing .git
url="${url%.git}"
# Re-strip trailing slash(es): a path remote ending in a `.git` directory
# component ("/repo/.git") exposes a new trailing slash once .git is gone.
while [ "${url%/}" != "$url" ]; do url="${url%/}"; done
printf '%s\n' "$url"
}
# ensure_file — create the policy file if missing, migrate if legacy.
# Emits the migration log line on stderr exactly once per run when a
# migration actually rewrites values.
ensure_file() {
require_jq
mkdir -p "$STATE_DIR"
if [ ! -f "$POLICY_FILE" ]; then
# Fresh file — just the schema version, no entries.
local tmp
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp"
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
return 0
fi
# File exists — validate, migrate if needed.
local raw
if ! raw=$(cat "$POLICY_FILE" 2>/dev/null); then
die "Cannot read $POLICY_FILE"
fi
# Corrupt JSON → quarantine and start fresh.
if ! echo "$raw" | jq empty 2>/dev/null; then
local ts
ts=$(date +%Y%m%d-%H%M%S)
local quarantine="$POLICY_FILE.corrupt-$ts"
mv "$POLICY_FILE" "$quarantine"
echo "gstack-gbrain-repo-policy: corrupt policy file quarantined to $quarantine; starting fresh" >&2
local tmp
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp"
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
return 0
fi
# Check schema version.
local version
version=$(echo "$raw" | jq -r '._schema_version // 0')
if [ "$version" -ge "$SCHEMA_VERSION" ]; then
return 0
fi
# Migrate: rename `allow` → `read-write`, add _schema_version.
local allow_count migrated
allow_count=$(echo "$raw" | jq '[to_entries[] | select(.key != "_schema_version" and .value == "allow")] | length')
migrated=$(echo "$raw" | jq --argjson v "$SCHEMA_VERSION" '
(to_entries | map(
if .key == "_schema_version" then empty
elif .value == "allow" then .value = "read-write"
else .
end
) | from_entries) + {_schema_version: $v}
')
local tmp
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
printf '%s\n' "$migrated" > "$tmp"
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
if [ "$allow_count" -gt 0 ]; then
echo "[gstack-gbrain-repo-policy] Migrated $allow_count legacy allow entries to read-write" >&2
fi
}
_tier_rank() {
case "$1" in deny) echo 0 ;; read-only) echo 1 ;; read-write) echo 2 ;; *) echo 3 ;; esac
}
# merge_legacy_tier KEY TIER — TIER, or the other root's deny/read-only tier
# when that is more restrictive. Never more permissive than TIER.
merge_legacy_tier() {
local key="$1" tier="$2" other
if [ -z "$LEGACY_POLICY_FILE" ]; then printf '%s\n' "$tier"; return 0; fi
other=$(jq -r --arg key "$key" '.[$key] // "none"' "$LEGACY_POLICY_FILE" 2>/dev/null) || other="none"
case "$other" in
deny|read-only)
if [ "$(_tier_rank "$other")" -lt "$(_tier_rank "$tier")" ]; then tier="$other"; fi ;;
esac
printf '%s\n' "$tier"
}
# get --batch — bulk lookup for ingest gates. One URL per stdin line, one
# tier per stdout line, input order preserved. Reuses normalize() (the same
# code path single `get` uses) per line. Prints `none` where single `get`
# prints `unset` — batch consumers (lib/gbrain-repo-policy-client.ts) speak
# the RepoPolicyTierValue vocabulary directly.
#
# Corruption polarity differs from single `get` ON PURPOSE: interactive
# `get` quarantines a corrupt store and starts fresh because /setup-gbrain
# re-asks the user; a batch caller is an unattended ingest gate with nobody
# to re-ask, so silently quarantining would BYPASS a set deny policy. Batch
# fails hard (exit 2) instead and names the recovery path.
cmd_get_batch() {
require_jq
if [ -n "$LEGACY_POLICY_FILE" ] && ! jq empty "$LEGACY_POLICY_FILE" 2>/dev/null; then
die "policy store $LEGACY_POLICY_FILE is corrupt (invalid JSON) — refusing batch read. Inspect or remove it; its deny tiers apply to every state root."
fi
if [ ! -f "$POLICY_FILE" ] && [ -n "$LEGACY_POLICY_FILE" ]; then
local url key
while IFS= read -r url || [ -n "$url" ]; do
key=$(normalize "$url")
if [ -z "$key" ]; then printf 'none\n'; continue; fi
merge_legacy_tier "$key" none
done
return 0
fi
if [ ! -f "$POLICY_FILE" ]; then
# No store = no policy was ever set. Every URL is `none`; don't create
# the file just for a read (matches cmd_list).
while IFS= read -r url || [ -n "$url" ]; do
printf 'none\n'
done
return 0
fi
if ! jq empty "$POLICY_FILE" 2>/dev/null; then
die "policy store $POLICY_FILE is corrupt (invalid JSON) — refusing batch read. Inspect with: gstack-gbrain-repo-policy list; re-run /setup-gbrain to rebuild the store."
fi
# Valid JSON from here, so ensure_file only performs the legacy
# allow → read-write migration (never the quarantine branch).
ensure_file
local url key
while IFS= read -r url || [ -n "$url" ]; do
key=$(normalize "$url")
if [ -z "$key" ]; then
printf 'none\n'
continue
fi
merge_legacy_tier "$key" "$(jq -r --arg key "$key" '.[$key] // "none"' "$POLICY_FILE")"
done
}
cmd_get() {
local url="${1:-}"
if [ "$url" = "--batch" ]; then
cmd_get_batch
return 0
fi
if [ -z "$url" ]; then
url=$(git config --get remote.origin.url 2>/dev/null || true)
if [ -z "$url" ]; then
echo "unset"
return 0
fi
fi
local key
key=$(normalize "$url")
if [ -z "$key" ]; then
echo "unset"
return 0
fi
ensure_file
local tier
tier=$(jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE")
if [ -n "$LEGACY_POLICY_FILE" ] && ! jq empty "$LEGACY_POLICY_FILE" 2>/dev/null; then
echo "gstack-gbrain-repo-policy: ignoring corrupt $LEGACY_POLICY_FILE (inspect or remove it)" >&2
LEGACY_POLICY_FILE=""
fi
merge_legacy_tier "$key" "$tier"
}
cmd_set() {
local url="${1:-}"
local tier="${2:-}"
[ -z "$url" ] && die "usage: set <remote-url> <tier>"
[ -z "$tier" ] && die "usage: set <remote-url> <tier>"
case "$tier" in
read-write|read-only|deny) ;;
*) die "invalid tier '$tier' (must be one of: read-write, read-only, deny)" ;;
esac
local key
key=$(normalize "$url")
[ -z "$key" ] && die "cannot normalize remote URL: $url"
ensure_file
local tmp
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp"
mv "$tmp" "$POLICY_FILE"
chmod 0600 "$POLICY_FILE"
echo "Set $key → $tier"
}
cmd_list() {
if [ ! -f "$POLICY_FILE" ]; then
# Nothing to list; don't create the file just for a read.
return 0
fi
ensure_file
jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort
}
cmd_normalize() {
local url="${1:-}"
[ -z "$url" ] && die "usage: normalize <url>"
normalize "$url"
}
case "${1:-}" in
get) shift; cmd_get "$@" ;;
set) shift; cmd_set "$@" ;;
list) shift; cmd_list "$@" ;;
normalize) shift; cmd_normalize "$@" ;;
--help|-h|help) sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//' ;;
"") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;;
*) die "unknown subcommand: $1" ;;
esac