#!/usr/bin/env bash # onboard-guardrails.sh — wire ANY machine's OpenCode to the ai-stack GUARDRAILS: # deterministic rule enforcement in the agent's tool-call path, powered by Cupcake # (OPA/Rego compiled to WASM) with policies GENERATED from the Backstage catalog # (content/orgs//ai-resources/rules/*.md, enforcement: block). # # A blocked call is stopped BEFORE any side effect and the agent is told which # rule fired, with a link back to the catalog entity — deterministic, not # advisory. See ansible-stacks/ai-stack/docs/PLAN-backstage-rule-enforcement.md. # # FIRST-TIMER ONE-LINER (from your project root): # curl -fsSL https://static-ai-stack.dc7.io/install/guardrails | bash -s -- install # # install [--global] [--project DIR] [--org NAME] [--no-binary] # status [--global] [--project DIR] # uninstall [--global] [--project DIR] # # WHAT IT DOES: # • installs the cupcake binary (~/.local/bin, sha256-verified from GitHub # releases; MAJOR is pinned by the server manifest, latest minor+patch wins). # • writes .opencode/plugin/cupcake.js (the official Cupcake OpenCode plugin, # version-matched to the binary; auto-discovered by opencode). # • fetches the org's policy bundle -> .cupcake/ (from # https://static-ai-stack.dc7.io/.well-known/cupcake//). # • MANDATORY self-block smoke test: a canned event that MUST deny. An # installed-but-inert enforcer manufactures false confidence — we refuse it. # # DESIGN GUARANTEES (mirror of the other onboarders): # • Idempotent: re-running never duplicates anything. # • Surgical: only OUR files are added; uninstall removes exactly them. # • Per-project by default (writes ./.opencode/ + ./.cupcake/); --global # writes ~/.config/opencode/ (bundle still lives per-project — policies # gate PROJECT work). # # Requires: bash, curl, tar, python3 (for JSON), shasum or sha256sum. if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 \ && [ -f "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-" ]; then exec bash "$0" "$@" fi set -euo pipefail # ── colors / logging ──────────────────────────────────────────────────────────── if [ -t 1 ]; then C_G=$'\033[32m'; C_Y=$'\033[33m'; C_R=$'\033[31m'; C_B=$'\033[1m'; C_0=$'\033[0m'; else C_G= C_Y= C_R= C_B= C_0=; fi log() { printf '%s%s%s\n' "$C_B" "$*" "$C_0"; } ok() { printf '%s✓%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf '%s!%s %s\n' "$C_Y" "$C_0" "$*" >&2; } die() { printf '%s✗%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } STATIC_URL="${GUARDRAILS_STATIC_URL:-https://static-ai-stack.dc7.io}" CUPCAKE_REPO="eqtylab/cupcake" # ── arg parsing ────────────────────────────────────────────────────────────────── CMD="${1:-}"; shift || true SCOPE="project"; PROJECT_DIR="$PWD"; ORG=""; WANT_BINARY=1 while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) PROJECT_DIR="${2:?}"; shift ;; --org) ORG="${2:?}"; shift ;; --no-binary) WANT_BINARY=0 ;; -h|--help) CMD="help" ;; *) die "unknown arg: $1" ;; esac shift done cfg_dir() { if [ "$SCOPE" = "global" ]; then echo "$HOME/.config/opencode"; else echo "$PROJECT_DIR/.opencode"; fi; } CFG_DIR="$(cfg_dir)" PLUGIN_FILE="$CFG_DIR/plugin/cupcake.js" # Policies are per-PROJECT even under --global: they gate project work. # EVERYTHING lives under .opencode/ (one place, like the other onboarders); # Cupcake hardcodes /.cupcake, so a SYMLINK bridges the two — # verified: `cupcake eval` resolves policies through it correctly. CUPCAKE_DIR="$PROJECT_DIR/.opencode/cupcake" CUPCAKE_LINK="$PROJECT_DIR/.cupcake" OURS_MARKER="$CFG_DIR/.guardrails-managed" BIN_DIR="$HOME/.local/bin" json() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d$1)" 2>/dev/null; } sha_check() { # sha_check local got if command -v shasum >/dev/null 2>&1; then got="$(shasum -a 256 "$1" | cut -d' ' -f1)"; else got="$(sha256sum "$1" | cut -d' ' -f1)"; fi [ "$got" = "$2" ] } # ── git hygiene (same header as the sibling onboarders) ───────────────────────── GI_HEADER="# ── ai-stack onboarders (local state — do not commit) ──" ensure_gitignore() { local gi="$CFG_DIR/.gitignore" line mkdir -p "$CFG_DIR" [ -f "$gi" ] || : > "$gi" if [ -s "$gi" ] && [ "$(tail -c1 "$gi" 2>/dev/null | wc -l)" -eq 0 ]; then printf '\n' >> "$gi" fi grep -qxF "$GI_HEADER" "$gi" 2>/dev/null || printf '%s\n' "$GI_HEADER" >> "$gi" # cupcake/: synced policy bundle + engine scaffolding + machine state — all # reproducible from `install`, none of it belongs in the repo. for line in '.*-managed' '.opencode.json.*-backup' 'cupcake/'; do grep -qxF "$line" "$gi" 2>/dev/null || printf '%s\n' "$line" >> "$gi" done } # ── server manifest ────────────────────────────────────────────────────────────── fetch_index() { curl -fsSL --max-time 15 "$STATIC_URL/.well-known/cupcake/index.json"; } resolve_org() { # Precedence: --org (CLI, for people who know what they want) # > previously-installed org (re-install/refresh keeps your org) # > the single published org # > die with instructions (portal resolves the PROFILE claim and # bakes --org into the command it shows you, so landing here # means a hand-typed install against a multi-org stack). local prev orgs n orgs="$(fetch_index | python3 -c 'import sys,json;print("\n".join(o["name"] for o in json.load(sys.stdin)["orgs"]))')" \ || die "cannot reach $STATIC_URL/.well-known/cupcake/index.json — is the stack up?" if [ -n "$ORG" ]; then # Validate an explicit --org against the published list NOW — otherwise it # 404s mid-install with a much worse message. printf '%s\n' "$orgs" | grep -qxF "$ORG" \ || die "org '$ORG' is not published (have: $(printf '%s' "$orgs" | tr '\n' ' '))" echo "$ORG"; return fi prev="$(cat "$CUPCAKE_DIR/.bundle-org" 2>/dev/null || true)" if [ -n "$prev" ]; then printf '%s\n' "$orgs" | grep -qxF "$prev" \ || die "previously installed org '$prev' is no longer published (have: $(printf '%s' "$orgs" | tr '\n' ' ')) — pass --org NAME" echo "$prev"; return fi n="$(printf '%s\n' "$orgs" | grep -c . || true)" [ "$n" = "1" ] && { printf '%s' "$orgs"; return; } die "multiple orgs published ($(printf '%s' "$orgs" | tr '\n' ' ')) — pass --org NAME, or install via the portal (https://portal-ai-stack.dc7.io), which resolves the org from the guardrails_org custom claim on your profile and bakes it into the command." } # ── cupcake binary (major pinned by the server; latest minor+patch) ───────────── platform_triple() { local os arch case "$(uname -s)" in Darwin) os="apple-darwin";; Linux) os="unknown-linux-gnu";; *) die "unsupported OS: $(uname -s)";; esac case "$(uname -m)" in x86_64) arch="x86_64";; aarch64|arm64) arch="aarch64";; *) die "unsupported arch: $(uname -m)";; esac echo "${arch}-${os}" } ensure_cupcake() { local major="$1" want_tag ver cur triple url tmp # Latest non-prerelease within the pinned major. want_tag="$(curl -fsSL --max-time 20 "https://api.github.com/repos/$CUPCAKE_REPO/releases" \ | python3 -c "import sys,json rs=[r['tag_name'] for r in json.load(sys.stdin) if not r['prerelease'] and r['tag_name'].startswith('v${major}.')] print(rs[0] if rs else '')")" [ -n "$want_tag" ] || die "no cupcake release found for major $major" ver="${want_tag#v}" # `cupcake --version` is NOT trustworthy for release identity: the v0.5.1 # release binary self-reports 0.3.0 (upstream build quirk, verified). Track # the installed release tag in a sidecar file instead. local tagfile="$BIN_DIR/.cupcake-release" if command -v cupcake >/dev/null 2>&1; then cur="$(cat "$tagfile" 2>/dev/null || cupcake --version 2>/dev/null | awk '{print $2}' || true)" case "$cur" in "$ver") ok "cupcake $cur present (latest in major $major)"; return 0 ;; "$major".*) log "cupcake $cur -> $ver (minor/patch update within major $major)" ;; *) [ "$WANT_BINARY" = "1" ] || die "cupcake $cur is outside pinned major $major — reinstall or drop --no-binary" ;; esac fi [ "$WANT_BINARY" = "1" ] || die "cupcake not found and --no-binary given — install it (brew/nix) and re-run" triple="$(platform_triple)" url="https://github.com/$CUPCAKE_REPO/releases/download/$want_tag/cupcake-$want_tag-$triple.tar.gz" tmp="$(mktemp -d)" log "installing cupcake $ver ($triple) -> $BIN_DIR …" curl -fsSL --max-time 180 -o "$tmp/c.tgz" "$url" || die "download failed: $url" curl -fsSL --max-time 30 -o "$tmp/c.sha" "$url.sha256" || die "checksum download failed" sha_check "$tmp/c.tgz" "$(cut -d' ' -f1 "$tmp/c.sha")" || die "sha256 mismatch for $url — refusing to install" mkdir -p "$BIN_DIR" tar xzf "$tmp/c.tgz" -C "$tmp" # Release layout: bin/cupcake + bin/opa (opa is needed for WASM compilation). local found found="$(find "$tmp" -type f -name cupcake | head -1)" [ -n "$found" ] || die "cupcake binary not found in the release archive" install -m 0755 "$found" "$BIN_DIR/cupcake" found="$(find "$tmp" -type f -name opa | head -1)" [ -n "$found" ] && install -m 0755 "$found" "$BIN_DIR/opa" || true rm -rf "$tmp" command -v "$BIN_DIR/cupcake" >/dev/null || die "install failed" printf '%s' "$ver" > "$tagfile" case ":$PATH:" in *":$BIN_DIR:"*) ;; *) warn "$BIN_DIR is not on PATH — add it to your shell profile";; esac ok "cupcake $ver installed" } # ── plugin ─────────────────────────────────────────────────────────────────────── ensure_plugin() { mkdir -p "$(dirname "$PLUGIN_FILE")" # The official plugin bundle is published under the rolling # opencode-plugin-latest tag as opencode-plugin.js (+ .sha256). Fetch + # verify directly, so we stay surgical about which files we touch. local url="https://github.com/$CUPCAKE_REPO/releases/download/opencode-plugin-latest/opencode-plugin.js" curl -fsSL --max-time 60 -o "$PLUGIN_FILE.tmp" "$url" || die "plugin download failed: $url" local want want="$(curl -fsSL --max-time 30 "$url.sha256" | cut -d' ' -f1)" || die "plugin checksum download failed" sha_check "$PLUGIN_FILE.tmp" "$want" || die "plugin sha256 mismatch — refusing" mv "$PLUGIN_FILE.tmp" "$PLUGIN_FILE" ok "plugin -> ${PLUGIN_FILE#$PROJECT_DIR/}" # Point the plugin at the binary explicitly (PATH inside opencode may differ). mkdir -p "$CUPCAKE_DIR" python3 - "$CUPCAKE_DIR/opencode.json" "$(command -v cupcake || echo "$BIN_DIR/cupcake")" <<'PY' import json, os, sys path, cupcake = sys.argv[1], sys.argv[2] cfg = {} if os.path.exists(path): try: cfg = json.load(open(path)) except Exception: cfg = {} cfg["cupcakePath"] = cupcake cfg.setdefault("failMode", "closed") cfg.setdefault("logLevel", "warn") json.dump(cfg, open(path, "w"), indent=1) PY } # ── policy bundle ──────────────────────────────────────────────────────────────── sync_bundle() { local org="$1" tmp manifest remote_ver local_ver sha manifest="$(curl -fsSL --max-time 15 "$STATIC_URL/.well-known/cupcake/$org/manifest.json")" \ || die "cannot fetch manifest for org '$org'" remote_ver="$(printf '%s' "$manifest" | json "['version']")" local_ver="$(cat "$CUPCAKE_DIR/.bundle-version" 2>/dev/null || true)" if [ "$remote_ver" = "$local_ver" ] && [ -d "$CUPCAKE_DIR/policies" ]; then ok "policy bundle $remote_ver already current (org $org)" return 0 fi sha="$(printf '%s' "$manifest" | json "['bundle_sha256']")" tmp="$(mktemp -d)" curl -fsSL --max-time 60 -o "$tmp/bundle.tar.gz" "$STATIC_URL/.well-known/cupcake/$org/bundle.tar.gz" \ || die "bundle download failed" sha_check "$tmp/bundle.tar.gz" "$sha" || die "bundle sha256 mismatch — refusing" # Unpack over .cupcake/ but PRESERVE local additions (policies/opencode/local/, # opencode.json): only files the bundle owns are replaced. tar xzf "$tmp/bundle.tar.gz" -C "$tmp" mkdir -p "$CUPCAKE_DIR/policies/opencode" # Remove previously-synced managed policies (deleted rules must disappear), # then copy the fresh set. Local dir + opencode.json are never touched. find "$CUPCAKE_DIR/policies/opencode" -maxdepth 1 -name '*.rego' -delete 2>/dev/null || true cp "$tmp/.cupcake/policies/opencode/"*.rego "$CUPCAKE_DIR/policies/opencode/" 2>/dev/null || true [ -f "$CUPCAKE_DIR/rulebook.yml" ] || cp "$tmp/.cupcake/rulebook.yml" "$CUPCAKE_DIR/rulebook.yml" # cupcake needs its system/ entrypoint (evaluate.rego) + helpers to run at # all — the bundle deliberately ships only OUR policies. Synthesize the # scaffolding via `cupcake init` when missing. Init refuses to touch an # existing .cupcake/, so run it in a scratch dir and copy the pieces in # (checked on the system/ dir, NOT .cupcake/ itself — opencode.json already # created the dir, which is how the first version of this script shipped an # engine that could not start). if [ ! -d "$CUPCAKE_DIR/system" ]; then local scratch scratch="$(mktemp -d)" ( cd "$scratch" && cupcake init --harness opencode >/dev/null 2>&1 ) \ || die "cupcake init failed — cannot synthesize the engine scaffolding" cp -R "$scratch/.cupcake/system" "$CUPCAKE_DIR/system" [ -d "$scratch/.cupcake/helpers" ] && cp -R "$scratch/.cupcake/helpers" "$CUPCAKE_DIR/helpers" rm -rf "$scratch" fi printf '%s' "$remote_ver" > "$CUPCAKE_DIR/.bundle-version" printf '%s' "$org" > "$CUPCAKE_DIR/.bundle-org" rm -rf "$tmp" ok "policy bundle $remote_ver synced (org $org, $(printf '%s' "$manifest" | json "['rules'].__len__()") rule(s))" } # ── .cupcake symlink: everything lives under .opencode/, Cupcake finds it here ── ensure_cupcake_link() { # Migrate a pre-symlink layout (real .cupcake dir from an earlier install). if [ -d "$CUPCAKE_LINK" ] && [ ! -L "$CUPCAKE_LINK" ]; then if [ -d "$CUPCAKE_DIR" ]; then warn "both $CUPCAKE_LINK (old layout) and $CUPCAKE_DIR exist — keeping .opencode/cupcake, archiving the old dir" mv "$CUPCAKE_LINK" "$CUPCAKE_LINK.pre-symlink-backup" else log "migrating .cupcake -> .opencode/cupcake (single-place layout)" mkdir -p "$PROJECT_DIR/.opencode" mv "$CUPCAKE_LINK" "$CUPCAKE_DIR" fi fi if [ -L "$CUPCAKE_LINK" ]; then # Repoint if it targets anything other than our dir (relative link). [ "$(readlink "$CUPCAKE_LINK")" = ".opencode/cupcake" ] || { rm -f "$CUPCAKE_LINK"; ln -s ".opencode/cupcake" "$CUPCAKE_LINK"; } else ln -s ".opencode/cupcake" "$CUPCAKE_LINK" fi ok ".cupcake -> .opencode/cupcake (symlink; one place for everything)" } # ── smoke test: the enforcer MUST block ────────────────────────────────────────── smoke_test() { local out decision out="$(cd "$PROJECT_DIR" && printf '%s' '{"hook_event_name":"PreToolUse","session_id":"smoke","cwd":".","tool":"write","args":{"filePath":"content/orgs/smoke/apis/x.yaml","content":"spec:\n password: smoketest123"}}' \ | cupcake eval --harness opencode 2>/dev/null | tail -1)" decision="$(printf '%s' "$out" | json "['decision']" || true)" if [ "$decision" = "deny" ]; then ok "smoke test: canned secret-write is BLOCKED (enforcer live)" else die "smoke test FAILED: expected deny, got '${decision:-nothing}' — enforcer would be INERT. Not installing half-working guardrails." fi } # ── commands ───────────────────────────────────────────────────────────────────── do_install() { local org major log "ai-stack guardrails — deterministic rule enforcement for OpenCode" org="$(resolve_org)" major="$(curl -fsSL --max-time 15 "$STATIC_URL/.well-known/cupcake/$org/manifest.json" | json "['cupcake']['major']")" \ || die "cannot read the org manifest" ensure_gitignore ensure_cupcake "$major" export PATH="$BIN_DIR:$PATH" ensure_cupcake_link ensure_plugin sync_bundle "$org" smoke_test date > "$OURS_MARKER" ok "guardrails installed (org $org). Blocked calls cite the Backstage rule entity." log "verify anytime: curl -fsSL $STATIC_URL/install/guardrails | bash -s -- status" } do_status() { local org ver remote command -v cupcake >/dev/null 2>&1 \ && ok "cupcake $(cat "$BIN_DIR/.cupcake-release" 2>/dev/null || cupcake --version 2>/dev/null | awk '{print $2}') (release tag)" \ || warn "cupcake binary not found" [ -f "$PLUGIN_FILE" ] && ok "plugin present: ${PLUGIN_FILE/#$HOME/~}" || warn "plugin missing" org="$(cat "$CUPCAKE_DIR/.bundle-org" 2>/dev/null || echo '?')" ver="$(cat "$CUPCAKE_DIR/.bundle-version" 2>/dev/null || echo 'none')" remote="$(curl -fsSL --max-time 10 "$STATIC_URL/.well-known/cupcake/$org/manifest.json" 2>/dev/null | json "['version']" || echo 'unreachable')" if [ "$ver" = "$remote" ]; then ok "policy bundle $ver (org $org) — current" else warn "policy bundle local=$ver remote=$remote (org $org) — re-run install to refresh"; fi } do_uninstall() { rm -f "$PLUGIN_FILE" # Only OUR synced artifacts; local policies + opencode.json stay. find "$CUPCAKE_DIR/policies/opencode" -maxdepth 1 -name '*.rego' -delete 2>/dev/null || true rm -f "$CUPCAKE_DIR/.bundle-version" "$CUPCAKE_DIR/.bundle-org" "$OURS_MARKER" # Drop the bridge symlink only if it is OURS (points into .opencode/). if [ -L "$CUPCAKE_LINK" ] && [ "$(readlink "$CUPCAKE_LINK")" = ".opencode/cupcake" ]; then rm -f "$CUPCAKE_LINK" fi ok "guardrails removed (cupcake binary left in place — remove $BIN_DIR/cupcake manually if unwanted)" } case "$CMD" in install) do_install ;; status) do_status ;; uninstall) do_uninstall ;; *) sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit "$([ "$CMD" = help ] && echo 0 || echo 1)" ;; esac