From d2b065972fd0fb07eacd1f0ceb8e879b1a84caa9 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:22:04 -0600 Subject: [PATCH 1/6] :sparkles::memo: Add HITL demo for session-budget plugin Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/README.md | 18 +- authbridge/demos/session-budget/README.md | 4 + authbridge/demos/session-budget/hitl-local.md | 314 ++++++++++++++++++ .../demos/session-budget/local/approver.go | 135 ++++++++ .../demos/session-budget/local/config.yaml | 34 ++ 5 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 authbridge/demos/session-budget/hitl-local.md create mode 100644 authbridge/demos/session-budget/local/approver.go create mode 100644 authbridge/demos/session-budget/local/config.yaml diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index f44bf8fe3..d45cd379d 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -20,7 +20,7 @@ more AuthBridge capabilities. | **[GitHub Issue Agent](github-issue/demo.md)** | Intermediate | Inbound validation + outbound token exchange + scope-based access control | [UI](github-issue/demo-ui.md) or [Manual](github-issue/demo-manual.md) | | **[Token-Exchange Routes](token-exchange-routes/README.md)** | Reference | How to write `authproxy-routes` for single- and multi-target token exchange | Configuration only | | **[MCP Parser Plugin](mcp-parser/README.md)** | Reference | Enable the `mcp-parser` plugin to surface tool calls / resource reads in session events | Configuration only | -| **[Session Budget](session-budget/README.md)** | Reference | Test assets for the `session-budget` plugin, including a pause-mode webhook stub | kubectl | +| **[Session Budget](session-budget/README.md)** | Reference | Test assets for the `session-budget` plugin, including a pause-mode webhook stub. Also see [`hitl-local.md`](session-budget/hitl-local.md) for a laptop-only walkthrough of `on_exceed: pause` (no Kubernetes required). | kubectl or local | | **[abctl Walkthrough](weather-agent/demo-with-abctl.md)** | Reference | Watch the AuthBridge plugin pipeline live with the `abctl` TUI | Tooling only | | **[IBAC](ibac/README.md)** | Intermediate | Intent-Based Access Control: LLM judge denies outbound HTTP that doesn't align with the user's recorded intent. Reproduces the email-poison / prompt-injection attack from `huang195/ibac`; chat with the agent through the rossoctl UI and see the exfiltration blocked, then `make show-result` for a pipeline-level forensic | UI + kubectl | | **[SPARC (finance)](finance-sparc/README.md)** | Intermediate | SPARC pre-tool reflection: the `sparc` plugin blocks a hallucinated/ungrounded tool argument (an invented transaction id) before it executes and transparently asks the user to clarify, then approves the corrected call. Complements IBAC — SPARC verifies argument grounding, IBAC verifies intent alignment | UI + kubectl | @@ -88,6 +88,22 @@ more AuthBridge capabilities. - Required `allow_mode_override: true` on the outbound ext_proc filter in envoy-sidecar mode +### Session Budget +- Cap how much an agent can spend per session — inference calls, + tokens, or wall-clock time with Redis-backed counters shared + across pods +- Three responses when a session hits its cap: `deny` (return 403), + `observe` (log only, don't block), or `pause` (call a webhook and + let a human approve or reject before the request continues) +- Cluster path ([`README.md`](session-budget/README.md)): production + shape — plugin runs as a sidecar, session IDs come from inbound + A2A requests, pause approvals go to a webhook stub deployed via + `kubectl` +- Local path ([`hitl-local.md`](session-budget/hitl-local.md)): + laptop-only walkthrough with Docker, `go run`, and `curl` — the + fastest way to see pause-mode approval end-to-end, no Kubernetes + required + ### abctl Walkthrough (Tooling Reference) - Run the `abctl` TUI against the weather-agent's session API - See inbound JWT validation → protocol parsers → outbound exchange diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md index 1a58355a7..82d39e626 100644 --- a/authbridge/demos/session-budget/README.md +++ b/authbridge/demos/session-budget/README.md @@ -4,6 +4,10 @@ Deployable helpers for exercising the `session-budget` plugin. For plugin configuration and mode semantics, see [`../../docs/session-budget-plugin.md`](../../docs/session-budget-plugin.md). +> **No cluster handy?** See [`hitl-local.md`](hitl-local.md) for a +> laptop-only walkthrough of `on_exceed: pause` (Docker + Go + curl, +> no Kubernetes). + ## `k8s/pause-webhook-stub.yaml` Minimal HITL webhook that returns `{"action":"approve"}` for every POST — diff --git a/authbridge/demos/session-budget/hitl-local.md b/authbridge/demos/session-budget/hitl-local.md new file mode 100644 index 000000000..eaeffe64a --- /dev/null +++ b/authbridge/demos/session-budget/hitl-local.md @@ -0,0 +1,314 @@ +# HITL pause mode — local demo + +A walkthrough of `on_exceed: pause` with a human at the terminal. +**No Kubernetes required** — this demo runs on your laptop with Docker +(Redis), Go (approver), and the shipping `authbridge-proxy` binary as +a forward proxy that `curl` points at explicitly. With no inbound A2A +session to tag the request, the plugin uses its +`default_session_fallback: true` path to bucket counters under +`"default"`; cluster deployments inherit a real session ID instead. + +For the cluster-based version, see [`README.md`](README.md). + +## What you'll see + +Four moving parts on one laptop: + +- **Redis** (Docker) — where session-budget persists per-session counters. +- **Ollama** — a real local LLM behind an OpenAI-compatible endpoint, + so `inference-parser` sees real `usage.total_tokens` on each response. +- **`authbridge-proxy`** — the shipping binary, wired as a forward + proxy with `inference-parser` + `session-budget` in the outbound + pipeline. `session-budget` is configured with `max_calls: 2` and + `on_exceed: pause`. +- **`approver.go`** — a ~130-line stdlib HTTP server on `:9099` that + prints each pause request and reads `a`/`d` from stdin. + +`curl` (through `HTTP_PROXY=127.0.0.1:47601`) drives the LLM. The first +two chat completions pass instantly. The third breaches the call +budget: the plugin blocks the request, POSTs to the approver, and only +resumes (or rejects with 403) once the operator decides. + +## How it fits together + +`authbridge-proxy` is the process. `inference-parser` and +`session-budget` are **plugins** the proxy loads and runs in-process on +every outbound request. There's no network hop between the proxy and +its plugins — the outbound pipeline is a function call chain inside +the same binary: + +``` + ┌───────────────────────────────────┐ + │ authbridge-proxy (:47601) │ + │ │ + curl ──HTTP_PROXY──▶ forward ─┼─▶ outbound pipeline │ + proxy │ ├─ session-budget (plugin) │ + │ └─ inference-parser (plugin) │ + │ │ ▲ │ + │ │ │ HGETALL / │ + │ │ │ HINCRBY │ + │ ▼ │ │ + │ ┌────────────────────┐ │ + │ │ Redis :6379 │ │ + │ │ session-budget:* │ │ + │ └────────────────────┘ │ + │ │ │ + │ on breach │ HTTP POST │ + │ ▼ │ + └────────────┼──────────────────────┘ + ▼ + approver.go (:9099) + │ + ▼ + Ollama :11434 + (only reached if approved + or under budget) +``` + +**Call flow for call #3 (the over-budget one):** + +``` +curl ─▶ authbridge-proxy ─▶ session-budget (reads Redis: calls=2, limit=2) + └▶ inference-parser (marks as inference call) + │ + │ over budget + on_exceed=pause + ▼ + POST http://localhost:9099 + │ + ▼ + approver.go prints prompt, + waits for a / d on stdin + │ + ┌─────────┴─────────┐ + ▼ ▼ + {"action":"approve"} {"action":"deny"} + │ │ + ▼ ▼ + call forwarded to 403 budget.exceeded + Ollama, response returned to curl + flows back to curl (Ollama never called) +``` + +## Prerequisites + +- `docker` (for Redis) +- `ollama serve` running with any small chat model pulled + (`ollama pull llama3.2:latest` works). Confirm the OpenAI endpoint: + + ```bash + curl -s http://localhost:11434/v1/models | jq -r '.data[].id' + ``` + +- Go 1.25+ for building the proxy binary. + +## Setup + +### Start Redis + +```bash +docker run --rm -d --name sb-demo-redis -p 6379:6379 redis:7-alpine +docker exec sb-demo-redis redis-cli PING # expect PONG +``` + +### Build the proxy binary (once) + +`session-budget` is opt-in via build tag (it links go-redis into the +binary). Build it in-tree from the `cmd/authbridge-proxy` module: + +```bash +cd authbridge/cmd/authbridge-proxy +go build -tags include_plugin_sessionbudget -o authbridge-proxy . +``` + +This produces `authbridge/cmd/authbridge-proxy/authbridge-proxy` — +that's the binary the rest of this doc invokes. + +### The config + +`authbridge/demos/session-budget/local/config.yaml`: + +```yaml +mode: proxy-sidecar +listener: + roles: [forward] # egress-only (no inbound listener) + forward_proxy_addr: "127.0.0.1:47601" + session_api_addr: "127.0.0.1:47604" +stats: + address: "127.0.0.1:47602" +pipeline: + outbound: + plugins: + # Order matters: RunResponseFrame dispatches in REVERSE declaration + # order, so session-budget (which reads Inference.TotalTokens) must + # be declared BEFORE inference-parser (which populates it). + - name: session-budget + config: + redis_url: "redis://localhost:6379" + max_calls: 2 + max_tokens: 1000000 # effectively unlimited; forces the + # plugin to track tokens in Redis so + # the watch pane shows real spend + on_exceed: "pause" + pause_webhook: "http://localhost:9099" + pause_timeout: "120s" + pause_timeout_action: "deny" + pause_grace_period: "1ms" # tiny grace so every over-budget call re-prompts + session_ttl_seconds: 300 + default_session_fallback: true # single-workload demo: pool + # sessionless egress into 'default' + - inference-parser +``` + +`inference-parser` reads `usage.total_tokens` on OpenAI-compatible +responses, `session-budget` counts every classified inference call +against `max_calls` AND accumulates `total_tokens` into a running +`tokens` counter, and Redis persists both under +`session-budget:`. This demo has no inbound A2A session, +so `default_session_fallback: true` pools all sessionless egress into +`session-budget:default`. The flag is off by default and should stay +off in multi-tenant deployments — one caller exhausting the shared +bucket denies all others. + +## Reset between runs + +`session-budget` persists per-session counters in Redis, so **the +second run of the demo starts already over-budget** unless you flush. +Every time you're about to demo (or re-demo) the pause path, reset +first: + +```bash +docker exec sb-demo-redis redis-cli FLUSHALL # zero the counters +``` + +The approver is stateless — leave it running between runs. Only kill +it if you want to switch its mode (interactive ↔ `--auto-approve` ↔ +`--auto-deny`). `pkill -f approver` doesn't always match the binary +that `go run` execs; if the new approver reports `address already in +use`, free the port directly: + +```bash +lsof -i :9099 -sTCP:LISTEN | awk 'NR>1 {print $2}' | xargs -r kill -9 +``` + +Optional — confirm the counter is actually zero before you start: + +```bash +docker exec sb-demo-redis redis-cli HGETALL session-budget:default +# empty output = fresh session, ready to demo +``` + +### Optional 4th pane — watch the counter live + +Add a fourth terminal that shows the Redis counter updating in real time: + +```bash +watch -n 0.5 'docker exec sb-demo-redis redis-cli HGETALL session-budget:default' +``` + +## Run it — three terminals + +### Terminal 1 — the approver + +Launch it in **interactive mode** (no flags) — the walkthrough below +depends on you typing `a`/`d` at the prompt: + +```bash +cd authbridge/demos/session-budget/local +go run ./approver.go +``` + +Expected: + +``` +approver listening on :9099 (auto-approve=false, auto-deny=false) +``` + +### Terminal 2 — the proxy + +From the repo root: + +```bash +./authbridge/cmd/authbridge-proxy/authbridge-proxy \ + -config ./authbridge/demos/session-budget/local/config.yaml +``` + +Expected (relevant lines): + +``` +level=INFO msg="HTTP server listening" name=forward-proxy addr=127.0.0.1:47601 +level=INFO msg="authbridge-proxy starting" mode=proxy-sidecar +``` + +### Terminal 3 — drive it with curl + +```bash +for i in 1 2 3; do + echo "=== call #$i ===" + curl -sS -x http://127.0.0.1:47601 \ + -H "Content-Type: application/json" \ + http://localhost:11434/v1/chat/completions \ + -d "{\"model\":\"llama3.2:latest\",\"messages\":[ + {\"role\":\"user\",\"content\":\"say hi in 3 words (call $i)\"} + ]}" \ + -w "\nhttp_status=%{http_code}\n" +done +``` + +What happens: + +- **Call #1 and #2** — pass instantly. Terminal 3 sees `200` and the + LLM's reply. Terminal 2 logs `inference-parser: response ... + promptTokens=... completionTokens=...`. If you added the 4th watch + pane, it shows both `calls` and `tokens` incrementing in Redis; the + `session-budget` call counter is now at `2/2`. +- **Call #3** — hangs. Terminal 2 logs `budget exceeded, requesting + approval reason="call limit reached: 2/2"`. Terminal 1 prints: + + ``` + ─── pause request ─── + session: default + reason: call limit reached: 2/2 + calls: 2 / 2 + tokens: 87 / 1000000 # tokens value will vary with the model + [a]pprove / [d]eny (default: approve): + ``` + + Type `a` and hit Enter → curl in Terminal 3 completes with `200`. + Type `d` → curl gets `403` with body: + + ```json + {"error":"budget.exceeded", + "message":"call limit reached: 2/2 (approval denied)", + "plugin":"session-budget", + "details":{"call_limit":2,"spent_calls":2,"spent_tokens":87,"token_limit":1000000}} + ``` + + Every subsequent over-budget call re-prompts (the 1ms grace window + is effectively off), so you can approve one, deny the next, and see + both outcomes in a single run. + +## Auto modes for CI + +The approver has `--auto-approve` and `--auto-deny` flags. They let you +smoke-test the wire without a human present: + +```bash +# Auto-deny: every over-budget call returns 403. +go run ./approver.go --auto-deny & +# then run the curl loop above — calls 1-2 pass, calls 3+ return 403. + +# Auto-approve: every over-budget call is waved through. +go run ./approver.go --auto-approve & +# then run the curl loop — all calls return 200. +``` + +## Cleanup + +```bash +pkill -f approver +pkill -f authbridge-proxy +docker rm -f sb-demo-redis +``` + +If you want to keep Redis running for another demo pass, skip the +`docker rm` and just re-run the "Reset between runs" flush. diff --git a/authbridge/demos/session-budget/local/approver.go b/authbridge/demos/session-budget/local/approver.go new file mode 100644 index 000000000..e32635f76 --- /dev/null +++ b/authbridge/demos/session-budget/local/approver.go @@ -0,0 +1,135 @@ +// approver.go is a demo-only HITL approver for the session-budget +// plugin's `on_exceed: pause` mode. It listens on an HTTP port, prints +// each incoming pause request, prompts the operator for [a]pprove or +// [d]eny, and returns the matching JSON response. +// +// This is a demo script, not a product surface — it lives under +// demos/session-budget/local/ and is not built into any release. Point +// session-budget's `pause_webhook` at this endpoint to demo the pause +// loop end-to-end: +// +// pause_webhook: "http://localhost:9099" +// +// Run: +// +// go run demos/session-budget/local/approver.go +// go run demos/session-budget/local/approver.go --auto-approve +// go run demos/session-budget/local/approver.go --addr :7000 +// +// Stdlib only — no go.mod, not part of the workspace, safe to run from +// any checkout. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// pauseRequest mirrors the wire type in +// authbridge/authlib/plugins/sessionbudget/plugin.go. +type pauseRequest struct { + SessionID string `json:"session_id"` + Reason string `json:"reason"` + SpentTokens int64 `json:"spent_tokens"` + SpentCalls int64 `json:"spent_calls"` + TokenLimit int64 `json:"token_limit"` + CallLimit int64 `json:"call_limit"` + DurationSeconds int64 `json:"duration_seconds,omitempty"` + DurationLimit int64 `json:"duration_limit,omitempty"` +} + +func main() { + addr := flag.String("addr", ":9099", "listen address") + autoApprove := flag.Bool("auto-approve", false, "skip the prompt and always approve") + autoDeny := flag.Bool("auto-deny", false, "skip the prompt and always deny") + flag.Parse() + + if *autoApprove && *autoDeny { + fmt.Fprintln(os.Stderr, "approver: --auto-approve and --auto-deny are mutually exclusive") + os.Exit(2) + } + + // Serialize prompts so concurrent pause requests queue rather than + // interleave keystrokes on stdin. + var promptMu sync.Mutex + stdin := bufio.NewReader(os.Stdin) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + raw, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var pr pauseRequest + if err := json.Unmarshal(raw, &pr); err != nil { + http.Error(w, "decode: "+err.Error(), http.StatusBadRequest) + return + } + + action := decide(&pr, &promptMu, stdin, *autoApprove, *autoDeny) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"action": action}) + }) + + fmt.Printf("approver listening on %s (auto-approve=%v, auto-deny=%v)\n", + *addr, *autoApprove, *autoDeny) + srv := &http.Server{ + Addr: *addr, + ReadHeaderTimeout: 5 * time.Second, + } + if err := srv.ListenAndServe(); err != nil { + fmt.Fprintln(os.Stderr, "approver:", err) + os.Exit(1) + } +} + +func decide(pr *pauseRequest, mu *sync.Mutex, stdin *bufio.Reader, autoApprove, autoDeny bool) string { + mu.Lock() + defer mu.Unlock() + + fmt.Println() + fmt.Println("─── pause request ───") + fmt.Printf(" session: %s\n", pr.SessionID) + fmt.Printf(" reason: %s\n", pr.Reason) + fmt.Printf(" calls: %d / %d\n", pr.SpentCalls, pr.CallLimit) + fmt.Printf(" tokens: %d / %d\n", pr.SpentTokens, pr.TokenLimit) + if pr.DurationLimit > 0 { + fmt.Printf(" age: %ds / %ds\n", pr.DurationSeconds, pr.DurationLimit) + } + + switch { + case autoApprove: + fmt.Println(" → approve (auto)") + return "approve" + case autoDeny: + fmt.Println(" → deny (auto)") + return "deny" + } + + fmt.Print(" [a]pprove / [d]eny (default: approve): ") + line, err := stdin.ReadString('\n') + if err != nil { + fmt.Printf(" (stdin closed: %v — defaulting to approve)\n", err) + return "approve" + } + line = strings.TrimSpace(strings.ToLower(line)) + if strings.HasPrefix(line, "d") { + fmt.Println(" → deny") + return "deny" + } + fmt.Println(" → approve") + return "approve" +} diff --git a/authbridge/demos/session-budget/local/config.yaml b/authbridge/demos/session-budget/local/config.yaml new file mode 100644 index 000000000..8f229acf8 --- /dev/null +++ b/authbridge/demos/session-budget/local/config.yaml @@ -0,0 +1,34 @@ +mode: proxy-sidecar + +listener: + roles: [forward] + forward_proxy_addr: "127.0.0.1:47601" + session_api_addr: "127.0.0.1:47604" + +stats: + address: "127.0.0.1:47602" + +pipeline: + outbound: + plugins: + # Order matters: RunResponseFrame dispatches in REVERSE declaration + # order (see authlib/pipeline/pipeline.go:196). session-budget reads + # pctx.Extensions.Inference.TotalTokens, so inference-parser must + # run first on the response — meaning it must be declared LAST here. + - name: session-budget + config: + redis_url: "redis://localhost:6379" + max_calls: 2 + max_tokens: 1000000 # effectively unlimited; forces the + # plugin to track tokens in Redis so + # the watch pane shows real spend + on_exceed: "pause" + pause_webhook: "http://localhost:9099" + pause_timeout: "120s" + pause_timeout_action: "deny" + pause_grace_period: "1ms" + session_ttl_seconds: 300 + default_session_fallback: true # single-workload demo: pool + # sessionless egress into the + # shared 'default' bucket + - inference-parser From eabce839a25c9a172f1442b7219801cdf17be7a9 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:53:18 -0600 Subject: [PATCH 2/6] :recycle: Make local demo more concise Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/hitl-local.md | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/authbridge/demos/session-budget/hitl-local.md b/authbridge/demos/session-budget/hitl-local.md index eaeffe64a..fe7156a5f 100644 --- a/authbridge/demos/session-budget/hitl-local.md +++ b/authbridge/demos/session-budget/hitl-local.md @@ -31,11 +31,8 @@ resumes (or rejects with 403) once the operator decides. ## How it fits together -`authbridge-proxy` is the process. `inference-parser` and -`session-budget` are **plugins** the proxy loads and runs in-process on -every outbound request. There's no network hop between the proxy and -its plugins — the outbound pipeline is a function call chain inside -the same binary: +The outbound pipeline is an in-process function chain with no network hop between +the proxy and its plugins: ``` ┌───────────────────────────────────┐ @@ -65,30 +62,6 @@ the same binary: or under budget) ``` -**Call flow for call #3 (the over-budget one):** - -``` -curl ─▶ authbridge-proxy ─▶ session-budget (reads Redis: calls=2, limit=2) - └▶ inference-parser (marks as inference call) - │ - │ over budget + on_exceed=pause - ▼ - POST http://localhost:9099 - │ - ▼ - approver.go prints prompt, - waits for a / d on stdin - │ - ┌─────────┴─────────┐ - ▼ ▼ - {"action":"approve"} {"action":"deny"} - │ │ - ▼ ▼ - call forwarded to 403 budget.exceeded - Ollama, response returned to curl - flows back to curl (Ollama never called) -``` - ## Prerequisites - `docker` (for Redis) @@ -187,7 +160,7 @@ that `go run` execs; if the new approver reports `address already in use`, free the port directly: ```bash -lsof -i :9099 -sTCP:LISTEN | awk 'NR>1 {print $2}' | xargs -r kill -9 +pids=$(lsof -ti :9099 -sTCP:LISTEN); [ -n "$pids" ] && kill $pids ``` Optional — confirm the counter is actually zero before you start: @@ -283,6 +256,8 @@ What happens: "details":{"call_limit":2,"spent_calls":2,"spent_tokens":87,"token_limit":1000000}} ``` + (`spent_tokens` will vary with the model, same as the prompt above.) + Every subsequent over-budget call re-prompts (the 1ms grace window is effectively off), so you can approve one, deny the next, and see both outcomes in a single run. @@ -290,7 +265,10 @@ What happens: ## Auto modes for CI The approver has `--auto-approve` and `--auto-deny` flags. They let you -smoke-test the wire without a human present: +smoke-test the wire without a human present. Stop any prior approver +first (see "Reset between runs" for the `lsof` one-liner) — a +backgrounded auto-mode approver will exit silently if `:9099` is +already bound. ```bash # Auto-deny: every over-budget call returns 403. @@ -300,12 +278,18 @@ go run ./approver.go --auto-deny & # Auto-approve: every over-budget call is waved through. go run ./approver.go --auto-approve & # then run the curl loop — all calls return 200. + +# Stop the approver when done — kill $! only reaps the `go run` parent +# and leaves the compiled server bound to :9099, so free the port directly: +pids=$(lsof -ti :9099 -sTCP:LISTEN); [ -n "$pids" ] && kill $pids ``` ## Cleanup ```bash -pkill -f approver +# pkill -f approver may miss the go-run child (see "Reset between runs"); +# freeing :9099 directly is more reliable. +pids=$(lsof -ti :9099 -sTCP:LISTEN); [ -n "$pids" ] && kill $pids pkill -f authbridge-proxy docker rm -f sb-demo-redis ``` From 7b35183f5ae19cde077a3817e3e7179d765a116d Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:10:39 -0600 Subject: [PATCH 3/6] :fire: Remove extraneous comments Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/local/approver.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/authbridge/demos/session-budget/local/approver.go b/authbridge/demos/session-budget/local/approver.go index e32635f76..5b590d7fd 100644 --- a/authbridge/demos/session-budget/local/approver.go +++ b/authbridge/demos/session-budget/local/approver.go @@ -3,21 +3,11 @@ // each incoming pause request, prompts the operator for [a]pprove or // [d]eny, and returns the matching JSON response. // -// This is a demo script, not a product surface — it lives under -// demos/session-budget/local/ and is not built into any release. Point -// session-budget's `pause_webhook` at this endpoint to demo the pause -// loop end-to-end: -// -// pause_webhook: "http://localhost:9099" -// // Run: // // go run demos/session-budget/local/approver.go // go run demos/session-budget/local/approver.go --auto-approve // go run demos/session-budget/local/approver.go --addr :7000 -// -// Stdlib only — no go.mod, not part of the workspace, safe to run from -// any checkout. package main import ( From 2894cb3132a6a1f31905654b8db4f263e8380a91 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:19:37 -0600 Subject: [PATCH 4/6] :art: Address review comments Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/README.md | 7 ++++++- authbridge/demos/session-budget/hitl-local.md | 10 +++++----- authbridge/demos/session-budget/local/approver.go | 9 +++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index d45cd379d..3646a1860 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -128,7 +128,8 @@ more AuthBridge capabilities. ## Prerequisites -All demos require: +Cluster-backed demos (everything above except the session-budget local +walkthrough) require: - A Kubernetes cluster with the Rossoctl platform installed ([Installation Guide](https://github.com/rossoctl/rossoctl/blob/main/docs/getting-started/install.md)) - Keycloak deployed in the `keycloak` namespace @@ -137,6 +138,10 @@ All demos require: UI-based demos additionally require: - The Rossoctl UI running at `http://rossoctl-ui.localtest.me:8080` +The session-budget [`hitl-local.md`](session-budget/hitl-local.md) +walkthrough is Kubernetes-free — see its own Prerequisites section +(Docker, Ollama, Go). + ## Common Setup: Keycloak Port-Forward Most demos need Keycloak accessible at `http://keycloak.localtest.me:8080`. diff --git a/authbridge/demos/session-budget/hitl-local.md b/authbridge/demos/session-budget/hitl-local.md index fe7156a5f..0f60a7188 100644 --- a/authbridge/demos/session-budget/hitl-local.md +++ b/authbridge/demos/session-budget/hitl-local.md @@ -34,7 +34,7 @@ resumes (or rejects with 403) once the operator decides. The outbound pipeline is an in-process function chain with no network hop between the proxy and its plugins: -``` +```text ┌───────────────────────────────────┐ │ authbridge-proxy (:47601) │ │ │ @@ -192,8 +192,8 @@ go run ./approver.go Expected: -``` -approver listening on :9099 (auto-approve=false, auto-deny=false) +```text +approver listening on 127.0.0.1:9099 (auto-approve=false, auto-deny=false) ``` ### Terminal 2 — the proxy @@ -207,7 +207,7 @@ From the repo root: Expected (relevant lines): -``` +```text level=INFO msg="HTTP server listening" name=forward-proxy addr=127.0.0.1:47601 level=INFO msg="authbridge-proxy starting" mode=proxy-sidecar ``` @@ -237,7 +237,7 @@ What happens: - **Call #3** — hangs. Terminal 2 logs `budget exceeded, requesting approval reason="call limit reached: 2/2"`. Terminal 1 prints: - ``` + ```text ─── pause request ─── session: default reason: call limit reached: 2/2 diff --git a/authbridge/demos/session-budget/local/approver.go b/authbridge/demos/session-budget/local/approver.go index 5b590d7fd..febd32d02 100644 --- a/authbridge/demos/session-budget/local/approver.go +++ b/authbridge/demos/session-budget/local/approver.go @@ -7,7 +7,7 @@ // // go run demos/session-budget/local/approver.go // go run demos/session-budget/local/approver.go --auto-approve -// go run demos/session-budget/local/approver.go --addr :7000 +// go run demos/session-budget/local/approver.go --addr 127.0.0.1:7000 package main import ( @@ -37,7 +37,7 @@ type pauseRequest struct { } func main() { - addr := flag.String("addr", ":9099", "listen address") + addr := flag.String("addr", "127.0.0.1:9099", "listen address") autoApprove := flag.Bool("auto-approve", false, "skip the prompt and always approve") autoDeny := flag.Bool("auto-deny", false, "skip the prompt and always deny") flag.Parse() @@ -79,6 +79,7 @@ func main() { srv := &http.Server{ Addr: *addr, ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, } if err := srv.ListenAndServe(); err != nil { fmt.Fprintln(os.Stderr, "approver:", err) @@ -112,8 +113,8 @@ func decide(pr *pauseRequest, mu *sync.Mutex, stdin *bufio.Reader, autoApprove, fmt.Print(" [a]pprove / [d]eny (default: approve): ") line, err := stdin.ReadString('\n') if err != nil { - fmt.Printf(" (stdin closed: %v — defaulting to approve)\n", err) - return "approve" + fmt.Printf(" (stdin closed: %v — failing closed to deny; use --auto-approve for unattended approvals)\n", err) + return "deny" } line = strings.TrimSpace(strings.ToLower(line)) if strings.HasPrefix(line, "d") { From 17f714d01c05c3253f666ecd4b17f7fe594e737c Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:45:09 -0600 Subject: [PATCH 5/6] :art: Address review comments Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/hitl-local.md | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/authbridge/demos/session-budget/hitl-local.md b/authbridge/demos/session-budget/hitl-local.md index 0f60a7188..2c5a1fadb 100644 --- a/authbridge/demos/session-budget/hitl-local.md +++ b/authbridge/demos/session-budget/hitl-local.md @@ -35,31 +35,33 @@ The outbound pipeline is an in-process function chain with no network hop betwee the proxy and its plugins: ```text - ┌───────────────────────────────────┐ - │ authbridge-proxy (:47601) │ - │ │ - curl ──HTTP_PROXY──▶ forward ─┼─▶ outbound pipeline │ - proxy │ ├─ session-budget (plugin) │ - │ └─ inference-parser (plugin) │ - │ │ ▲ │ - │ │ │ HGETALL / │ - │ │ │ HINCRBY │ - │ ▼ │ │ - │ ┌────────────────────┐ │ - │ │ Redis :6379 │ │ - │ │ session-budget:* │ │ - │ └────────────────────┘ │ - │ │ │ - │ on breach │ HTTP POST │ - │ ▼ │ - └────────────┼──────────────────────┘ - ▼ - approver.go (:9099) - │ + ┌────────────────────────────────────┐ + │ authbridge-proxy (:47601) │ + │ │ + curl ──HTTP_PROXY──▶ forward ─┼─▶ outbound pipeline │ + proxy │ ├─ session-budget (plugin) │ + │ └─ inference-parser (plugin) │ + │ │ ▲ │ + │ │ │ HGETALL / │ + │ │ │ HINCRBY │ + │ ▼ │ │ + │ ┌────────────────────┐ │ + │ │ Redis :6379 │ │ + │ │ session-budget:* │ │ + │ └────────────────────┘ │ + │ on breach │ ▲ │ + │ HTTP POST │ │ {"action":...} │ + │ ▼ │ │ + │ ┌────────────────┐ │ + │ │ approver.go │ │ + │ │ (:9099) │ │ + │ └────────────────┘ │ + │ under budget, OR approver said │ + │ approve: proxy forwards │ + │ │ │ + └────────────┼───────────────────────┘ ▼ Ollama :11434 - (only reached if approved - or under budget) ``` ## Prerequisites @@ -72,7 +74,8 @@ the proxy and its plugins: curl -s http://localhost:11434/v1/models | jq -r '.data[].id' ``` -- Go 1.25+ for building the proxy binary. +- Go toolchain matching `authbridge/cmd/authbridge-proxy/go.mod` for + building the proxy binary. ## Setup From 01fd64bad4f70c6c238d391c18723f4d363178d1 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:32:19 -0600 Subject: [PATCH 6/6] :art: Address review comments Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/hitl-local.md | 8 +- .../demos/session-budget/local/approver.go | 76 +++++++++++++++---- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/authbridge/demos/session-budget/hitl-local.md b/authbridge/demos/session-budget/hitl-local.md index 2c5a1fadb..954b6e313 100644 --- a/authbridge/demos/session-budget/hitl-local.md +++ b/authbridge/demos/session-budget/hitl-local.md @@ -242,14 +242,14 @@ What happens: ```text ─── pause request ─── - session: default - reason: call limit reached: 2/2 + session: "default" + reason: "call limit reached: 2/2" calls: 2 / 2 tokens: 87 / 1000000 # tokens value will vary with the model - [a]pprove / [d]eny (default: approve): + [a]pprove / [d]eny (Enter = approve): ``` - Type `a` and hit Enter → curl in Terminal 3 completes with `200`. + Type `a` (or just Enter) → curl in Terminal 3 completes with `200`. Type `d` → curl gets `403` with body: ```json diff --git a/authbridge/demos/session-budget/local/approver.go b/authbridge/demos/session-budget/local/approver.go index febd32d02..148055587 100644 --- a/authbridge/demos/session-budget/local/approver.go +++ b/authbridge/demos/session-budget/local/approver.go @@ -12,6 +12,7 @@ package main import ( "bufio" + "context" "encoding/json" "flag" "fmt" @@ -50,7 +51,22 @@ func main() { // Serialize prompts so concurrent pause requests queue rather than // interleave keystrokes on stdin. var promptMu sync.Mutex - stdin := bufio.NewReader(os.Stdin) + + // One long-running reader owns os.Stdin; decide reads answers off + // this channel. That way a pause whose request context cancels can + // abandon its prompt without racing another decide on stdin. + lines := make(chan string) + go func() { + r := bufio.NewReader(os.Stdin) + for { + line, err := r.ReadString('\n') + if err != nil { + close(lines) + return + } + lines <- line + } + }() http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -68,7 +84,7 @@ func main() { return } - action := decide(&pr, &promptMu, stdin, *autoApprove, *autoDeny) + action := decide(r.Context(), &pr, &promptMu, lines, *autoApprove, *autoDeny) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"action": action}) @@ -87,14 +103,16 @@ func main() { } } -func decide(pr *pauseRequest, mu *sync.Mutex, stdin *bufio.Reader, autoApprove, autoDeny bool) string { +func decide(ctx context.Context, pr *pauseRequest, mu *sync.Mutex, lines <-chan string, autoApprove, autoDeny bool) string { mu.Lock() defer mu.Unlock() fmt.Println() fmt.Println("─── pause request ───") - fmt.Printf(" session: %s\n", pr.SessionID) - fmt.Printf(" reason: %s\n", pr.Reason) + // %q on caller-supplied fields so an embedded escape sequence + // cannot rewrite the calls/tokens lines the operator decides on. + fmt.Printf(" session: %q\n", pr.SessionID) + fmt.Printf(" reason: %q\n", pr.Reason) fmt.Printf(" calls: %d / %d\n", pr.SpentCalls, pr.CallLimit) fmt.Printf(" tokens: %d / %d\n", pr.SpentTokens, pr.TokenLimit) if pr.DurationLimit > 0 { @@ -110,17 +128,43 @@ func decide(pr *pauseRequest, mu *sync.Mutex, stdin *bufio.Reader, autoApprove, return "deny" } - fmt.Print(" [a]pprove / [d]eny (default: approve): ") - line, err := stdin.ReadString('\n') - if err != nil { - fmt.Printf(" (stdin closed: %v — failing closed to deny; use --auto-approve for unattended approvals)\n", err) - return "deny" + // Drain any answer typed for a prior request whose context already + // canceled — a stale keystroke should not decide the current one. + for { + select { + case _, ok := <-lines: + if !ok { + fmt.Println(" (stdin closed — failing closed to deny; use --auto-approve for unattended approvals)") + return "deny" + } + continue + default: + } + break } - line = strings.TrimSpace(strings.ToLower(line)) - if strings.HasPrefix(line, "d") { - fmt.Println(" → deny") - return "deny" + + for { + fmt.Print(" [a]pprove / [d]eny (Enter = approve): ") + select { + case <-ctx.Done(): + fmt.Println() + fmt.Println(" (request canceled by caller — failing closed to deny)") + return "deny" + case line, ok := <-lines: + if !ok { + fmt.Println(" (stdin closed — failing closed to deny; use --auto-approve for unattended approvals)") + return "deny" + } + switch strings.TrimSpace(strings.ToLower(line)) { + case "", "a", "approve": + fmt.Println(" → approve") + return "approve" + case "d", "deny": + fmt.Println(" → deny") + return "deny" + default: + fmt.Println(" (unrecognized — type 'a' to approve or 'd' to deny)") + } + } } - fmt.Println(" → approve") - return "approve" }