mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
v0.42.72.0 feat(auth): server-enforced slug-prefix write fence for OAuth clients + qm-harness integration guide (#3712)
Registering an OAuth client with --bound-slug-prefixes now makes the write boundary real: writes outside the bound prefixes are refused on every op that can name a page, and ops that write by something other than a slug are refused outright rather than left unfenced. Deny-by-default at dispatch, so a write op added later is refused to bound clients until it is explicitly fenced. Adds docs/integrations/qm-harness.md (gbrain as the company brain for a qm deployment) with a roster-driven provisioning script and deployment templates, plus a Known limitations section stating plainly that this is a write boundary and not a privacy boundary. Five review rounds, including three clean-room passes by codex gpt-5.6-sol and Claude Fable 5 against an instruction-stripped tree.
This commit is contained in:
+29
-4
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.42.72.0] - 2026-08-01
|
||||
|
||||
**Per-person write isolation inside a shared source, and a guide for putting gbrain behind a multi-user agent harness.**
|
||||
|
||||
Until now, `--source` was the only write boundary: a client could write anywhere inside the source it was scoped to, and keeping each person in their own folder was a convention the agent had to honor by itself. Registering a client with `--bound-slug-prefixes` now makes that boundary real. Writes outside the bound prefixes are refused by the server, on every op that can name a page.
|
||||
|
||||
**Adding a binding to an existing client narrows it on purpose.** Ops that write by something other than a page slug can't be confined to a prefix, so a bound client is refused them outright rather than left with an unfenced path: `extract_entities`, `extract_facts`, `forget_fact`, `ontology_propose`, `sources_add`/`sources_remove`, and `POST /ingest`. `put_page`'s automatic fact extraction is skipped for the same reason — it writes to entity pages the caller never named. Reads are unaffected, and unbound clients behave exactly as before. The gate keys on "anything that is not a plain read", so an op added in a future release is refused to bound clients until it is explicitly fenced.
|
||||
|
||||
Both prefix spellings work: the `wiki/agents/alice/*` glob that `submit_agent` bindings already use, and the plainer `emp-alice/` form. Change a binding in place with `gbrain auth rescope-client <id> --bound-slug-prefixes <p1,p2|none>` — existing tokens pick it up on their next request, so no secret rotation is needed when someone joins or leaves a team.
|
||||
|
||||
**New guide: [gbrain as the company brain for a qm deployment](docs/integrations/qm-harness.md).** qm is a multiplayer agent harness where each employee and each channel gets an isolated agent scope. The guide covers the whole path — one central `gbrain serve --http`, the thin-client binary baked into the sandbox image, one OAuth client per scope, and a roster-driven provisioning script that converges the brain to a list of people and channels. It also states plainly what the model does *not* give you: within a shared source, reads stay source-granular, so prefix isolation is a write boundary, not a privacy boundary.
|
||||
gbrain upgrade # or: bun install -g gbrain@0.42.72.0
|
||||
gbrain apply-migrations --yes # required: the fence refuses writes it cannot evaluate
|
||||
```
|
||||
|
||||
To fence an existing client to a folder:
|
||||
|
||||
```bash
|
||||
gbrain auth rescope-client <client_id> --bound-slug-prefixes partners/alice-example/
|
||||
gbrain auth rescope-client <client_id> --bound-slug-prefixes none # undo
|
||||
```
|
||||
|
||||
Verify it took, from a client holding that credential — the first write should succeed and the second should be refused:
|
||||
|
||||
```bash
|
||||
gbrain put partners/alice-example/notes/test --content "mine"
|
||||
gbrain put partners/bob-example/notes/test --content "not mine"
|
||||
```
|
||||
|
||||
## [0.42.71.0] - 2026-08-01
|
||||
|
||||
**GBrain now publishes real releases. Every version bump from here on lands on the [Releases page](https://github.com/garrytan/gbrain/releases) with organized notes and downloadable binaries — and binary self-update finally works.**
|
||||
@@ -46,10 +75,6 @@ Contributed by @time-attack (#3573, closing #3521).
|
||||
**Windows and self-hosters.** Markdown files keep LF endings so frontmatter parsers stop mis-reading on Windows checkouts; the archive-crawler path gate no longer denies every real Windows path (and no longer fail-opens on NTFS case-insensitivity); a chat-synopsis tier that was hardcoded to one provider now follows your configured models; vector search asks the index for as many candidates as it was told to consider.
|
||||
|
||||
**Quieter, more honest infrastructure.** `serve --http` no longer leaves an orphan holding the database lock after Ctrl-C; a minion child that fails to launch settles immediately instead of hanging its slot; doctor gains checks for content-hash duplicates, undeclared database-only pages, stale heartbeats, and a tamper-evident manifest for the skills directory; federated reads respect per-source isolation settings in two more paths; and the security docs were rewritten to describe fixes without cataloguing attack surface.
|
||||
|
||||
### To take advantage of v0.42.70.0
|
||||
|
||||
```bash
|
||||
gbrain upgrade
|
||||
gbrain extract --stale # re-extracts links under the fixed resolver
|
||||
gbrain doctor # includes the new silent-failure checks
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -44,6 +44,7 @@ These require manual setup (no self-installing recipe yet):
|
||||
|-------|-------------|
|
||||
| [Credential Gateway](credential-gateway.md) | Set up ClawVisor or Hermes for Gmail, Calendar, Contacts access |
|
||||
| [Meeting & Call Webhooks](meeting-webhooks.md) | Circleback meeting transcripts + Quo/OpenPhone SMS/calls |
|
||||
| [qm Harness](qm-harness.md) | gbrain as the company brain for a qm (multi-user agent harness) deployment — central HTTP MCP, per-scope clients, roster provisioning, write fencing |
|
||||
|
||||
## How to Read a Recipe
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: gbrain
|
||||
description: Search and write the company knowledge brain. Use for any question about the org, people, projects, decisions, or history, and to persist durable knowledge beyond this scope's notebook.
|
||||
---
|
||||
|
||||
# gbrain — the company brain
|
||||
|
||||
This sandbox has the `gbrain` CLI connected (thin-client) to the org's central
|
||||
brain. It is the deep, indexed, cross-source memory: org docs, shared channel
|
||||
knowledge, and every agent's durable notes. Your scope's own notebook stays the
|
||||
fast per-turn memory; the brain is where knowledge outlives a scope and becomes
|
||||
searchable by everyone entitled to it.
|
||||
|
||||
## First-run setup (once per sandbox — skip if `gbrain remote doctor` passes)
|
||||
|
||||
Your scope's brain credentials arrive via the deployment's secret handoff
|
||||
(keychain entry or one-time secret drop named `gbrain`). Then:
|
||||
|
||||
```bash
|
||||
gbrain init --mcp-only \
|
||||
--issuer-url "https://brain.<org>.com" \
|
||||
--mcp-url "https://brain.<org>.com/mcp" \
|
||||
--oauth-client-id "<client id from the handoff>" \
|
||||
--oauth-client-secret "<client secret from the handoff>"
|
||||
gbrain whoami # must succeed before using any other command
|
||||
```
|
||||
|
||||
Pass the secret with `--oauth-client-secret`, not via `GBRAIN_REMOTE_CLIENT_SECRET`:
|
||||
an env-sourced secret is deliberately NOT written to `~/.gbrain/config.json`, so
|
||||
every later command would fail with "No client_secret available" once the
|
||||
variable is out of scope. The flag persists it to the config file on this
|
||||
sandbox's durable disk, which is what the tool's credential capture expects.
|
||||
|
||||
Do not run `gbrain remote doctor` — it needs `admin` scope, which your client
|
||||
does not have (by design). `gbrain whoami` is the read-scope health check.
|
||||
|
||||
## Reading (do this liberally)
|
||||
|
||||
```bash
|
||||
gbrain search "who decided X and why" # hybrid semantic + keyword search
|
||||
gbrain get <slug> # read one page
|
||||
gbrain query "question" --json # search tuned for agent consumption
|
||||
```
|
||||
|
||||
You can read: the shared agent-memory source, org read-only sources (wiki,
|
||||
handbook), and everything under them. Reads are isolation-enforced server-side;
|
||||
you only ever see sources your client is entitled to.
|
||||
|
||||
## Writing (durable knowledge only, under YOUR prefixes)
|
||||
|
||||
Your client is write-fenced to slug prefixes — your own namespace plus the
|
||||
channels you belong to. Writes outside them are rejected server-side.
|
||||
|
||||
```bash
|
||||
# personal durable memory (your namespace):
|
||||
gbrain put emp-<your-slug>/people/jane-example --content "..."
|
||||
|
||||
# shared channel knowledge (channels you are in):
|
||||
gbrain put chan-eng/decisions/2026-08-database-choice --content "..."
|
||||
```
|
||||
|
||||
Conventions:
|
||||
- Write conclusions and durable facts, not chat transcripts. One page per
|
||||
entity/decision/topic; update the page rather than appending near-duplicates.
|
||||
- Markdown with YAML frontmatter; the brain chunks, embeds, and links it.
|
||||
- Cross-reference liberally: `gbrain link <from> <to>` (from must be in your
|
||||
namespace; linking TO any readable page is fine).
|
||||
- When you learn something channel-relevant in personal work, mirror the
|
||||
conclusion into the channel prefix with a `(said in <where>)` provenance
|
||||
note.
|
||||
|
||||
## When to reach for the brain
|
||||
|
||||
- Any question about the org, a person, a project, a decision, or history →
|
||||
`gbrain search` FIRST, then answer.
|
||||
- You produced knowledge with value beyond this conversation → `gbrain put`.
|
||||
- Something looks wrong (auth errors, empty results you don't expect) →
|
||||
`gbrain whoami` to confirm which client and scopes you're using, and report
|
||||
its output.
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env bash
|
||||
# provision-scopes.sh — roster-driven gbrain provisioning for a qm deployment
|
||||
# (or any multi-user agent harness with per-person + per-channel scopes).
|
||||
#
|
||||
# Reads a roster of channels + employees and converges the brain to it:
|
||||
# - ensures the shared agent-memory source exists (path-less: agents write
|
||||
# pages into it over MCP; `gbrain sync` skips it; if the brain host has
|
||||
# sync.repo_path configured, pages also write through to .sources/<id>/
|
||||
# on disk for git-backed durability)
|
||||
# - registers one OAuth client per employee, write-fenced via
|
||||
# bound_slug_prefixes to emp-<slug>/ plus chan-<c>/ for each channel
|
||||
# they are in, with federated reads over the memory source + any
|
||||
# read-only sources you pass
|
||||
# - re-running after roster edits rescopes existing clients IN PLACE
|
||||
# (client ids are remembered in the state file; secrets never rotate
|
||||
# unless you revoke + delete the state row)
|
||||
#
|
||||
# Usage:
|
||||
# provision-scopes.sh roster.tsv \
|
||||
# [--memory-source agents] [--read-sources org-wiki,handbook] \
|
||||
# [--budget-usd-per-day 5] [--state-file roster.state.tsv] \
|
||||
# [--secrets-out new-credentials.tsv] [--gbrain gbrain] [--dry-run]
|
||||
#
|
||||
# Roster format (one entry per line; '#' comments and blank lines ignored):
|
||||
# channel <slug>
|
||||
# employee <slug> [comma-separated channel slugs]
|
||||
#
|
||||
# SECURITY: --secrets-out receives client secrets for NEW registrations,
|
||||
# written exactly once (gbrain never re-shows them). Deliver each row to its
|
||||
# scope's sandbox (e.g. via the harness keychain or a one-time secret drop),
|
||||
# then delete the file.
|
||||
#
|
||||
# ponytail: sequential CLI loop, one gbrain invocation per roster row — fine
|
||||
# to hundreds of employees; batch via the admin API if that ever hurts.
|
||||
|
||||
# -f (noglob) is load-bearing, not stylistic: roster lines are word-split
|
||||
# unquoted below, so without it a line like `employee * eng` would expand
|
||||
# against the working directory and silently provision a filename as a
|
||||
# person — i.e. the wrong write fence. Nothing here needs globbing.
|
||||
set -euf -o pipefail
|
||||
|
||||
# Client secrets and the id state file are written by this script; 077 makes
|
||||
# them 0600 instead of the default 0644. Set before the first file is created.
|
||||
umask 077
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# Slugs become source ids, client names, AND slug-prefix write fences. The
|
||||
# fence list is comma-separated, so an unvalidated slug containing a comma
|
||||
# would inject an EXTRA prefix and hand the client write access to someone
|
||||
# else's namespace. Fail closed on anything that isn't plain kebab-case.
|
||||
valid_slug() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
-*|*-) return 1 ;;
|
||||
*[!a-z0-9-]*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
require_slug() {
|
||||
valid_slug "$2" || die "roster: invalid $1 slug '$2' (allowed: lowercase a-z, 0-9, interior hyphens)"
|
||||
}
|
||||
|
||||
ROSTER="${1:-}"
|
||||
[ -n "$ROSTER" ] && [ -f "$ROSTER" ] || die "usage: provision-scopes.sh <roster-file> [flags] (roster not found: '$ROSTER')"
|
||||
shift
|
||||
|
||||
GBRAIN="${GBRAIN:-gbrain}"
|
||||
MEMORY_SOURCE="agents"
|
||||
READ_SOURCES=""
|
||||
BUDGET="5"
|
||||
STATE_FILE=""
|
||||
SECRETS_OUT=""
|
||||
DRY_RUN=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--memory-source) MEMORY_SOURCE="$2"; shift 2 ;;
|
||||
--read-sources) READ_SOURCES="$2"; shift 2 ;;
|
||||
--budget-usd-per-day) BUDGET="$2"; shift 2 ;;
|
||||
--state-file) STATE_FILE="$2"; shift 2 ;;
|
||||
--secrets-out) SECRETS_OUT="$2"; shift 2 ;;
|
||||
--gbrain) GBRAIN="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
*) die "unknown flag: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
STATE_FILE="${STATE_FILE:-${ROSTER}.state.tsv}"
|
||||
SECRETS_OUT="${SECRETS_OUT:-${ROSTER}.new-credentials.tsv}"
|
||||
|
||||
# The roster usually lives in the deployment repo, so the default secrets and
|
||||
# state paths land there too — one `git add -A` from committing live
|
||||
# credentials. The STATE file matters as much as the secrets file: it maps
|
||||
# employee -> client_id, and this script feeds that id straight to
|
||||
# `rescope-client`, so whoever can write it decides which client receives a
|
||||
# given employee's write authority. Treat both as privileged infrastructure,
|
||||
# at the same trust level as the roster itself.
|
||||
for f in "$SECRETS_OUT" "$STATE_FILE"; do
|
||||
if git -C "$(dirname "$f")" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "WARN: $f is inside a git work tree. Never commit it;" >&2
|
||||
echo " gitignore it, or pass --secrets-out/--state-file outside the repo." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# A group/world-writable parent directory defeats the symlink and ownership
|
||||
# checks below: anyone with write access there can swap the file between our
|
||||
# check and our append. Refuse rather than pretend the checks hold.
|
||||
for d in "$(dirname "$SECRETS_OUT")" "$(dirname "$STATE_FILE")"; do
|
||||
perms=$(ls -ld "$d" | awk '{print $1}')
|
||||
case "$perms" in
|
||||
?????w*|????????w*) die "refusing to write credentials into a group/world-writable directory: $d ($perms)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Secure the credential sinks BEFORE anything is appended. umask only governs
|
||||
# files this script creates; a pre-existing world-readable file would receive
|
||||
# secrets first and be chmod'ed only afterwards, and a symlink planted at
|
||||
# either path would redirect them entirely.
|
||||
for f in "$SECRETS_OUT" "$STATE_FILE"; do
|
||||
[ -L "$f" ] && die "refusing to write credentials through a symlink: $f"
|
||||
if [ -e "$f" ]; then
|
||||
[ -f "$f" ] || die "refusing to write credentials to a non-regular file: $f"
|
||||
[ -O "$f" ] || die "refusing to write credentials to a file owned by another user: $f"
|
||||
else
|
||||
: > "$f"
|
||||
fi
|
||||
chmod 600 "$f"
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = 1 ]; then echo "DRY-RUN: $GBRAIN $*" >&2; return 0; fi
|
||||
# shellcheck disable=SC2086 — $GBRAIN may carry args ("bun run src/cli.ts")
|
||||
$GBRAIN "$@"
|
||||
}
|
||||
|
||||
state_lookup() { # state_lookup <employee-slug> -> client_id or empty
|
||||
[ -f "$STATE_FILE" ] || return 0
|
||||
awk -F'\t' -v s="$1" '$1 == s { print $2; exit }' "$STATE_FILE"
|
||||
}
|
||||
|
||||
# ── Pass 1: parse roster, collect declared channels ─────────────────────────
|
||||
CHANNELS=""
|
||||
EMPLOYEES=""
|
||||
lineno=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
lineno=$((lineno + 1))
|
||||
line="${line%%#*}"
|
||||
line="${line%$'\r'}" # a CRLF roster would otherwise yield 'emp-alice\r/' prefixes that fence everything out
|
||||
[ -z "${line//[[:space:]]/}" ] && continue
|
||||
# shellcheck disable=SC2086 — deliberate word split; globbing is off (set -f above)
|
||||
set -- $line
|
||||
[ "$#" -le 3 ] || die "roster line $lineno: too many fields ('$line'). Channels are ONE comma-separated field with no spaces: 'employee alice eng,product'"
|
||||
case "$1" in
|
||||
channel)
|
||||
require_slug channel "${2:-}"
|
||||
CHANNELS="$CHANNELS $2"
|
||||
;;
|
||||
employee)
|
||||
require_slug employee "${2:-}"
|
||||
case " $EMPLOYEES " in *" $2:"*) die "roster line $lineno: employee '$2' listed twice" ;; esac
|
||||
if [ -n "${3:-}" ]; then
|
||||
for c in ${3//,/ }; do require_slug "channel-reference" "$c"; done
|
||||
fi
|
||||
EMPLOYEES="$EMPLOYEES $2:${3:-}"
|
||||
;;
|
||||
*) die "roster line $lineno: unknown entry type '$1' (expected 'channel' or 'employee')" ;;
|
||||
esac
|
||||
done < "$ROSTER"
|
||||
|
||||
# ── Pass 2: ensure the shared memory source exists (path-less) ──────────────
|
||||
if out=$(run sources add "$MEMORY_SOURCE" --name "agent memory ($MEMORY_SOURCE)" 2>&1); then
|
||||
echo "source '$MEMORY_SOURCE': created"
|
||||
else
|
||||
echo "$out" | grep -q "already registered" || die "sources add failed: $out"
|
||||
echo "source '$MEMORY_SOURCE': already exists"
|
||||
fi
|
||||
|
||||
# ── Pass 3: converge one client per employee ────────────────────────────────
|
||||
FED_READ="$MEMORY_SOURCE${READ_SOURCES:+,$READ_SOURCES}"
|
||||
new_secrets=0
|
||||
|
||||
for entry in $EMPLOYEES; do
|
||||
slug="${entry%%:*}"
|
||||
chans="${entry#*:}"
|
||||
|
||||
prefixes="emp-$slug/"
|
||||
if [ -n "$chans" ]; then
|
||||
for c in ${chans//,/ }; do
|
||||
echo " $CHANNELS " | grep -q " $c " || echo "WARN: employee '$slug' references undeclared channel '$c'" >&2
|
||||
prefixes="$prefixes,chan-$c/"
|
||||
done
|
||||
fi
|
||||
|
||||
client_id="$(state_lookup "$slug")"
|
||||
if [ -n "$client_id" ]; then
|
||||
# The state file usually sits in the deployment repo, so anyone who can
|
||||
# edit it could otherwise retarget this privileged rescope at an arbitrary
|
||||
# client id (e.g. point alice's row at an admin client). Shape-check it.
|
||||
case "$client_id" in
|
||||
gbrain_cl_) die "state file: empty client id for '$slug'" ;;
|
||||
gbrain_cl_*[!a-zA-Z0-9_]*) die "state file: malformed client id for '$slug': $client_id" ;;
|
||||
gbrain_cl_*) ;;
|
||||
*) die "state file: client id for '$slug' does not look like a gbrain client: $client_id" ;;
|
||||
esac
|
||||
# --source too, so a re-run actually CONVERGES the client to the roster:
|
||||
# without it, changing --memory-source (or inheriting a state row written
|
||||
# against an older one) silently leaves the old write source in place
|
||||
# while the script reports success.
|
||||
run auth rescope-client "$client_id" --source "$MEMORY_SOURCE" \
|
||||
--federated-read "$FED_READ" --bound-slug-prefixes "$prefixes" >/dev/null
|
||||
echo "employee '$slug': rescoped $client_id [write: $prefixes]"
|
||||
elif [ "$DRY_RUN" = 1 ]; then
|
||||
echo "employee '$slug': WOULD register qm-emp-$slug [write: $prefixes] [read: $FED_READ]"
|
||||
continue
|
||||
else
|
||||
out=$(run auth register-client "qm-emp-$slug" \
|
||||
--grant-types client_credentials --scopes "read write" \
|
||||
--source "$MEMORY_SOURCE" --federated-read "$FED_READ" \
|
||||
--bound-slug-prefixes "$prefixes" --budget-usd-per-day "$BUDGET" 2>&1) \
|
||||
|| die "register-client failed for '$slug' (output withheld: it can contain a secret). Re-run the command by hand to see why."
|
||||
client_id=$(echo "$out" | sed -n 's/.*Client ID:[[:space:]]*\(gbrain_cl_[^[:space:]]*\).*/\1/p' | head -1)
|
||||
secret=$(echo "$out" | sed -n 's/.*Client Secret:[[:space:]]*\(gbrain_cs_[^[:space:]]*\).*/\1/p' | head -1)
|
||||
if [ -z "$client_id" ] || [ -z "$secret" ]; then
|
||||
# The client may well have been created — dying silently would strand a
|
||||
# live credential nobody can find. Say so WITHOUT echoing the captured
|
||||
# output: it contains the freshly minted secret, and this path ends up
|
||||
# in CI logs.
|
||||
die "could not parse client id/secret for '$slug' from register-client output (output withheld: it contains a secret). A client MAY have been created; check \`gbrain auth list\` and revoke any stray 'qm-emp-$slug'."
|
||||
fi
|
||||
printf '%s\t%s\n' "$slug" "$client_id" >> "$STATE_FILE"
|
||||
printf '%s\t%s\t%s\n' "$slug" "$client_id" "$secret" >> "$SECRETS_OUT"
|
||||
chmod 600 "$STATE_FILE" "$SECRETS_OUT" 2>/dev/null || true # umask covers new files; this covers pre-existing ones
|
||||
new_secrets=$((new_secrets + 1))
|
||||
echo "employee '$slug': registered $client_id [write: $prefixes]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Pass 4: flag offboarded employees ───────────────────────────────────────
|
||||
# Removing someone from the roster is the highest-stakes edit there is, and
|
||||
# this script cannot safely revoke on its own (a typo'd roster would nuke live
|
||||
# credentials). Report instead, with the exact command.
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
while IFS=$'\t' read -r st_slug st_client _rest; do
|
||||
[ -n "${st_slug:-}" ] || continue
|
||||
case " $EMPLOYEES " in
|
||||
*" $st_slug:"*) ;;
|
||||
*) echo "STALE: '$st_slug' ($st_client) is no longer in the roster but its credentials still work." >&2
|
||||
echo " Revoke with: $GBRAIN auth revoke-client $st_client" >&2 ;;
|
||||
esac
|
||||
done < "$STATE_FILE"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Done. State: $STATE_FILE"
|
||||
if [ "$new_secrets" -gt 0 ]; then
|
||||
echo "$new_secrets NEW client secret(s) written to $SECRETS_OUT — deliver to each scope's sandbox, then DELETE the file."
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
# Roster for provision-scopes.sh — one line per channel / employee.
|
||||
# channel <slug>
|
||||
# employee <slug> [comma-separated channels they belong to]
|
||||
|
||||
channel eng
|
||||
channel product
|
||||
|
||||
employee alice-example eng,product
|
||||
employee bob-example eng
|
||||
employee carol-example
|
||||
|
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "gbrain",
|
||||
"label": "gbrain company brain",
|
||||
"advertise": "gbrain",
|
||||
"hints": [
|
||||
"Company knowledge brain: searchable, cross-source, persistent.",
|
||||
"Search it BEFORE answering questions about the org, people, projects, decisions, or history: `gbrain search \"<question>\"`.",
|
||||
"Write durable knowledge with `gbrain put <slug> --content ...`, only under your own slug prefixes.",
|
||||
"See the gbrain skill for slug conventions and first-run setup."
|
||||
],
|
||||
"auth": {
|
||||
"check": "gbrain whoami",
|
||||
"reauth": "gbrain init --mcp-only --force --issuer-url \"$GBRAIN_ISSUER_URL\" --mcp-url \"$GBRAIN_MCP_URL\" --oauth-client-id \"$GBRAIN_CLIENT_ID\" --oauth-client-secret \"$GBRAIN_CLIENT_SECRET\"",
|
||||
"credentialPaths": [
|
||||
{ "path": ".gbrain/config.json", "kind": "file" }
|
||||
]
|
||||
},
|
||||
"install": { "binary": "gbrain" }
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
# qm (multi-user agent harness) — gbrain as the company brain
|
||||
|
||||
Connect gbrain to [qm](https://github.com/yc-software/qm) — the multiplayer
|
||||
agent harness where each employee and each channel gets an isolated agent
|
||||
scope — so every scope's agent can search and write one shared, indexed,
|
||||
isolation-enforced company brain. The same recipe fits any harness with
|
||||
per-person sandboxes that can run a CLI.
|
||||
|
||||
**Shape:** one central `gbrain serve --http` (OAuth 2.1) next to qm's core;
|
||||
the `gbrain` binary baked into qm's sandbox image as a thin client; one OAuth
|
||||
client per employee, read-fenced by source federation and write-fenced by
|
||||
`bound_slug_prefixes`. Zero qm code changes — everything lives in the qm
|
||||
*deployment directory*.
|
||||
|
||||
qm's native memory (per-scope notebook) stays as-is for fast per-turn recall.
|
||||
gbrain adds what qm doesn't have: semantic + hybrid search, cross-scope
|
||||
knowledge, entity graphs, and durable memory that outlives a scope.
|
||||
|
||||
## Topology
|
||||
|
||||
| gbrain concept | qm concept |
|
||||
|---|---|
|
||||
| one brain (one Postgres/Supabase DB) | the org |
|
||||
| source `agents` (path-less, shared) | all agent-written memory |
|
||||
| slug prefix `emp-<slug>/` in `agents` | an employee's personal scope |
|
||||
| slug prefix `chan-<slug>/` in `agents` | a channel/room scope |
|
||||
| source `org-wiki` (git-backed, read-only) | company docs |
|
||||
| OAuth client `qm-emp-<slug>` | one employee's agent identity |
|
||||
|
||||
Isolation model:
|
||||
|
||||
- **Reads** are source-granular, SQL-enforced (`federated_read`): every
|
||||
employee client reads `agents` + the read-only sources you grant.
|
||||
- **Writes** are slug-prefix-granular, server-enforced (`bound_slug_prefixes`,
|
||||
v0.42.72.0+): a client can only mutate pages under its own `emp-<slug>/`
|
||||
and its channels' `chan-<x>/` prefixes — on `put_page`, `delete_page`,
|
||||
`restore_page`, `add_tag`, `remove_tag`, `add_link`/`remove_link`,
|
||||
`add_timeline_entry`, `revert_version` and `put_raw_data`, plus the
|
||||
`POST /ingest` webhook route. Not by convention.
|
||||
- **Every op that is not a plain read is denied unless allow-listed.** Ops
|
||||
that write by a key other than a slug — `extract_entities` and
|
||||
`extract_facts` (which mutate `people/*` and `companies/*`), `forget_fact`
|
||||
(targets a fact by numeric id, across sources), `ontology_propose`, and the
|
||||
`sources_admin` pair `sources_add`/`sources_remove` — cannot be fenced by
|
||||
slug, so a bound client gets `permission_denied` at dispatch. The gate keys
|
||||
on "not a pure read", not on a list of scope strings, so a write op added
|
||||
later (or one carrying a bespoke scope) is denied until it is explicitly
|
||||
fenced and added to `CLIENT_FENCED_WRITE_OPS` (`src/core/operations.ts`).
|
||||
`think` is allow-listed because remote callers cannot persist from it;
|
||||
`submit_agent` because it enforces this same column itself.
|
||||
- **Indirect write paths are gated too, not just the ops.** `put_page`'s
|
||||
facts backstop would otherwise extract entities from the page body and
|
||||
write fact rows (and a `## Facts` fence on git-backed sources) onto
|
||||
`people/*` pages the caller never named — the same capability
|
||||
`extract_facts` is denied for, reached through an in-prefix write. It is
|
||||
skipped for bound clients. `POST /ingest` is refused outright: its handler
|
||||
bypasses the op layer *and* discards the source grant for untrusted
|
||||
payloads, so it would write into the `default` source.
|
||||
### Known limitations — read these before you rely on the fence
|
||||
|
||||
The write fence is a **write** boundary within a source. It is not a privacy
|
||||
boundary, and it does not make every side effect prefix-clean. As of
|
||||
v0.42.72.0:
|
||||
|
||||
- **`add_link`/`remove_link` fence the `from` endpoint only.** A bound client
|
||||
can create an edge pointing AT a page it cannot write; the edge's `context`
|
||||
text surfaces in that page's backlinks and contributes to its search
|
||||
ranking. Fencing `to` would break legitimate cross-referencing into
|
||||
`org-wiki`, so this is deliberate — treat inbound-edge context as untrusted
|
||||
content, the same way you treat page bodies.
|
||||
- **Reads are source-granular, never prefix-granular.** Everyone entitled to
|
||||
a source can read every prefix in it. If a scope needs genuine read
|
||||
privacy, give it its own source.
|
||||
- **`put_page` can create one reverse graph edge outside the fence.** If a
|
||||
page body cites a code location (`src/x.ts:42`) and a code page for it
|
||||
exists *in the same source*, doc↔impl reconciliation adds an edge
|
||||
originating from that code page. It affects graph/backlink ranking, not
|
||||
page content. Unreachable in the layout above (the `agents` source is
|
||||
path-less and holds no code pages); it applies only if you point employee
|
||||
writes at a code-synced source.
|
||||
- **A few read ops are still brain-wide** and ignore the federated grant:
|
||||
`get_recent_salience`, `find_anomalies`, `find_contradictions`, and
|
||||
`sources_list`/`sources_status` (which expose source ids, paths and URLs).
|
||||
A read-scoped client can learn facts derived from sources it was not
|
||||
granted. Pre-existing, not introduced by the fence; if that matters for
|
||||
your deployment, withhold those tools at the harness layer for now.
|
||||
- **Reads touch `last_retrieved_at`** on the pages they return, including
|
||||
pages in read-only sources. Freshness/usage signals are therefore
|
||||
writable-by-reading; nothing else about the page is.
|
||||
- **`POST /ingest` writes land in the `default` source** regardless of the
|
||||
calling client's `source_id`, because the handler discards the source for
|
||||
untrusted payloads. Bound clients are refused the route outright for this
|
||||
reason; if you point a webhook integration at it, scope that brain's
|
||||
`default` source deliberately.
|
||||
- **Tradeoff to state out loud:** read isolation is per-source, so within the
|
||||
shared `agents` source every employee can *read* every prefix (including
|
||||
other employees' `emp-*/`). That matches qm's transparent-by-default,
|
||||
everything-audited posture. If you need hard read privacy for personal
|
||||
memory, give those employees their own write source instead of a prefix
|
||||
(one `sources add emp-<slug>` + `--source emp-<slug>` per client) and keep
|
||||
channel prefixes in `agents` via a second, channels-only client — at the
|
||||
cost of two credentials in that sandbox.
|
||||
|
||||
## Host setup (the machine running qm's core, or any box its sandboxes can reach)
|
||||
|
||||
```bash
|
||||
# 1. Engine: Postgres/Supabase. PGLite is single-process and cannot serve
|
||||
# many concurrent sandboxes.
|
||||
gbrain init --supabase --embedding-model voyage:voyage-4-large
|
||||
|
||||
# 2. Modes + gates (publish_* default OFF and fail as silent 403s):
|
||||
gbrain config set search.mode balanced
|
||||
gbrain config set mcp.publish_skills true
|
||||
gbrain config set mcp.publish_advisor true
|
||||
|
||||
# 3. Read-only org sources + first sync:
|
||||
gbrain sources add org-wiki --path ~/brains/org-wiki
|
||||
gbrain sync --all # cron this
|
||||
|
||||
# 4. Serve over HTTP MCP (OAuth 2.1):
|
||||
gbrain serve --http --bind 0.0.0.0 --port 3131 \
|
||||
--public-url https://brain.acme-example.com
|
||||
```
|
||||
|
||||
Never hand sandboxes `DATABASE_URL` — direct DB access bypasses OAuth, source
|
||||
federation, and the write fence entirely.
|
||||
|
||||
## Provision scopes from a roster
|
||||
|
||||
[`qm-harness-snippets/provision-scopes.sh`](qm-harness-snippets/provision-scopes.sh)
|
||||
converges the brain to a roster file
|
||||
([`roster.example.tsv`](qm-harness-snippets/roster.example.tsv)):
|
||||
|
||||
```bash
|
||||
bash provision-scopes.sh roster.tsv --read-sources org-wiki
|
||||
```
|
||||
|
||||
- Creates the path-less `agents` source (agent-written memory needs no git
|
||||
clone; if the host has `sync.repo_path` configured, pages also write
|
||||
through to `.sources/agents/` for git-backed durability).
|
||||
- Registers `qm-emp-<slug>` clients: `--scopes "read write"`,
|
||||
`--source agents`, `--federated-read agents,org-wiki`,
|
||||
`--bound-slug-prefixes emp-<slug>/,chan-<a>/,...`, per-day budget.
|
||||
- **Idempotent:** re-run after every roster edit; existing clients are
|
||||
`rescope-client`ed in place (channel joins/leaves update the write fence
|
||||
without rotating secrets).
|
||||
- New client secrets land once in `<roster>.new-credentials.tsv` — deliver
|
||||
each row to its scope (qm keychain / one-time secret drop), then delete
|
||||
the file.
|
||||
|
||||
## qm deployment directory
|
||||
|
||||
In the org's qm deployment repo (the directory `qm init` produced):
|
||||
|
||||
1. **Tool:** copy [`qm-harness-snippets/tool.json`](qm-harness-snippets/tool.json)
|
||||
to `sandbox/tools/gbrain/tool.json` and drop the compiled `gbrain` binary
|
||||
beside it (`bun build --compile --outfile gbrain src/cli.ts`, built for
|
||||
the sandbox image's OS/arch). `auth.credentialPaths` marks
|
||||
`~/.gbrain/config.json` as the scope's resident credential file;
|
||||
`auth.check` wires `gbrain whoami` into qm's connector status (read-scope;
|
||||
see the note below on why `remote doctor` cannot be used here).
|
||||
2. **Skill:** copy [`qm-harness-snippets/SKILL.md`](qm-harness-snippets/SKILL.md)
|
||||
to `sandbox/skills/gbrain/SKILL.md` (edit slug conventions to taste).
|
||||
3. Ship it: `qm sandbox build && qm sandbox publish && qm up`.
|
||||
|
||||
Per scope, one-time (agent- or operator-run, credentials from the handoff):
|
||||
|
||||
```bash
|
||||
gbrain init --mcp-only \
|
||||
--issuer-url https://brain.acme-example.com \
|
||||
--mcp-url https://brain.acme-example.com/mcp \
|
||||
--oauth-client-id gbrain_cl_... --oauth-client-secret gbrain_cs_...
|
||||
gbrain whoami # must succeed
|
||||
```
|
||||
|
||||
Use `--oauth-client-secret`, not `GBRAIN_REMOTE_CLIENT_SECRET`: an env-sourced
|
||||
secret is deliberately not written to `~/.gbrain/config.json`
|
||||
(`src/commands/init.ts`), so with the env var alone every later command fails
|
||||
once it leaves scope — and qm's `sandbox.secretEnv` is org-wide, so there is no
|
||||
per-scope env to keep it in. With the flag, the credential lands in the config
|
||||
file on the scope's durable disk and this runs once per scope, ever.
|
||||
|
||||
`gbrain remote doctor` is **not** the health check here: `run_doctor` is an
|
||||
`admin`-scope op and these clients are `read write` on purpose. `gbrain whoami`
|
||||
is read-scope and reports the client's identity, source, and grants.
|
||||
|
||||
## Verify isolation before rollout
|
||||
|
||||
From two differently-scoped sandboxes (or two thin-client configs):
|
||||
|
||||
```bash
|
||||
# alice-example (bound to emp-alice-example/, chan-eng/):
|
||||
gbrain put emp-alice-example/notes/test --content "mine" # OK
|
||||
gbrain put chan-eng/notes/test --content "shared" # OK
|
||||
gbrain put emp-bob-example/notes/test --content "not mine" # permission_denied
|
||||
gbrain put chan-product/notes/test --content "not my channel" # permission_denied
|
||||
gbrain search "test" # sees agents + org-wiki only
|
||||
```
|
||||
|
||||
## Cost + operations
|
||||
|
||||
- `search.mode balanced` (12K token budget, relational retrieval on) is the
|
||||
right default for a startup fleet; see `docs/guides/search-modes.md` for
|
||||
the cost matrix before changing it.
|
||||
- Budgets: `--budget-usd-per-day` is recorded on the client but only enforced
|
||||
on the `submit_agent` path (`src/core/minions/budget-meter.ts`), which these
|
||||
`read write` clients cannot reach — so it does **not** cap spend from
|
||||
ordinary `search`/`put_page` traffic. Treat runaway-agent containment as an
|
||||
open item: watch the admin SPA (`/admin`) and `gbrain search stats`, and cap
|
||||
at the model/harness layer.
|
||||
- Backfills on a live brain: `gbrain embed --stale --pace` (see Pace Mode in
|
||||
CLAUDE.md / `docs/operations/spend-controls.md`).
|
||||
|
||||
## Deliberately deferred
|
||||
|
||||
- **qm `MemoryService` decorator** (mirror notebook captures into gbrain,
|
||||
fan `recall` out and merge, `volunteer_context` push): needs a qm code
|
||||
change; today's integration is agent-initiated via the CLI + skill.
|
||||
- **MCP-native attach:** qm pins `strictMcpConfig` with only its in-process
|
||||
server, so gbrain's MCP-discovered brain-resident skillpacks don't reach
|
||||
qm agents; the sandbox skill above covers it.
|
||||
- **Read-side prefix fencing** (hard privacy for `emp-*/` inside a shared
|
||||
source) — tracked upstream; the roster layout is forward-compatible with
|
||||
it.
|
||||
@@ -93,7 +93,7 @@ There are two ways to scope teammates' access. They suit different deployment sh
|
||||
|
||||
**Model A: separate sources with OAuth scoping (recommended for true multi-user with different AI clients).** What this tutorial walks you through. Each teammate gets their own OAuth client, which carries `--source` + `--federated-read` flags. The brain refuses cross-source reads at the SQL layer; isolation is database-enforced. Each teammate can run their own MCP-aware client (Claude Code, Cursor, their own OpenClaw, etc.) and the scoping holds.
|
||||
|
||||
**Model B: one source, directory-based per-person scoping (simpler for one-agent-serves-everyone setups).** The shape I actually run in production: a single source called `default`, with a `partners/<slug>/` convention inside it (e.g. `partners/alice-example/`, `partners/bob-example/`). Each partner gets their own subdirectory holding their personal pages: `partners/alice-example/USER.md`, `partners/alice-example/concepts/`, `partners/alice-example/sources/`, etc. There's no OAuth-enforced isolation; the agent itself enforces "Alice's writes go to her partners/ subdir." This is the right model when ONE agent (yours) serves everyone over Telegram or a single shared interface. It's simpler ops, no per-user OAuth, but the scoping is convention-only.
|
||||
**Model B: one source, directory-based per-person scoping (simpler for one-agent-serves-everyone setups).** The shape I actually run in production: a single source called `default`, with a `partners/<slug>/` convention inside it (e.g. `partners/alice-example/`, `partners/bob-example/`). Each partner gets their own subdirectory holding their personal pages: `partners/alice-example/USER.md`, `partners/alice-example/concepts/`, `partners/alice-example/sources/`, etc. This is the right model when ONE agent (yours) serves everyone over Telegram or a single shared interface. It's simpler ops, no per-user OAuth. **Write scoping within the shared source can be server-enforced:** register each per-person client with `--bound-slug-prefixes partners/alice-example/` and every slug-mutating write outside that prefix is rejected with `permission_denied` (v0.42.72.0+). Without the binding, the scoping is convention-only (the agent polices itself). Read scoping stays source-granular in both models — within a shared source, everyone entitled to the source can read every folder.
|
||||
|
||||
For most company-brain installs (10+ teammates each with their own AI client), Model A is the right starting point. If you're running the fat-agent-serves-everyone pattern from the personal-brain tutorial, Model B is genuinely simpler. You can also mix: separate sources for the obviously-different ones (customer notes vs internal-only) AND a `partners/<slug>/` convention inside the shared source for per-person workspace.
|
||||
|
||||
@@ -210,7 +210,7 @@ Each `register-client` command prints a `client_id` and a `client_secret`. Save
|
||||
A note on the flags:
|
||||
|
||||
- `--scopes read,write` lets the client query the brain and write new pages. You can omit `write` for read-only clients (executive summaries, dashboards). The `admin` scope is needed for operational commands like `gbrain remote doctor` and is usually reserved for your own admin client.
|
||||
- `--source` controls write authority. A client can only write to one source. Within that source, your folder convention from Part 3 keeps each person's writes in their own subfolder.
|
||||
- `--source` controls write authority. A client can only write to one source. Within that source, your folder convention from Part 3 keeps each person's writes in their own subfolder — and you can make that server-enforced with `--bound-slug-prefixes alice-example/` (v0.42.72.0+): every slug-mutating write op (put_page, delete_page, tags, links, timeline, revert, raw data) outside the bound prefixes is rejected with `permission_denied`. Update the binding later with `gbrain auth rescope-client <id> --bound-slug-prefixes <p1,p2|none>`. **Adding a binding to an existing client narrows it in ways you should expect:** ops that write by something other than a slug (`extract_entities`, `extract_facts`, `forget_fact`, `ontology_propose`, `sources_add`/`sources_remove`) and `POST /ingest` become unavailable to that client, and `put_page`'s automatic fact extraction is skipped — all because none of them can be confined to a prefix. Reads are unaffected. See [the qm-harness guide](../integrations/qm-harness.md) for the full model.
|
||||
- `--federated-read` controls read scope. A client can read from one or more sources.
|
||||
|
||||
### Verify the scoping actually scopes
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.42.71.0",
|
||||
"version": "0.42.72.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.4",
|
||||
|
||||
+25
-6
@@ -524,13 +524,17 @@ async function registerClient(name: string, args: string[]) {
|
||||
* /admin/api/rescope-client endpoint.
|
||||
*/
|
||||
async function rescopeClient(clientId: string, args: string[]) {
|
||||
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...]';
|
||||
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...] [--bound-slug-prefixes P1,P2|none]';
|
||||
if (!clientId) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
let sourceId: string | undefined;
|
||||
let federatedRead: string[] | undefined;
|
||||
// v0.42.72.0: tri-state — undefined = untouched, null = clear ('none'),
|
||||
// array = replace. Lets roster churn (channel joins/leaves) update the
|
||||
// write fence in place instead of register+rotate.
|
||||
let boundSlugPrefixes: string[] | null | undefined;
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const flag = args[i];
|
||||
const value = args[i + 1];
|
||||
@@ -542,14 +546,18 @@ async function rescopeClient(clientId: string, args: string[]) {
|
||||
if (flag === '--source') sourceId = value;
|
||||
else if (flag === '--federated-read') {
|
||||
federatedRead = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else if (flag === '--bound-slug-prefixes') {
|
||||
boundSlugPrefixes = value === 'none'
|
||||
? null
|
||||
: value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
console.error(`Error: Unknown flag: ${flag}`);
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (sourceId === undefined && federatedRead === undefined) {
|
||||
console.error('Error: pass --source and/or --federated-read');
|
||||
if (sourceId === undefined && federatedRead === undefined && boundSlugPrefixes === undefined) {
|
||||
console.error('Error: pass --source, --federated-read, and/or --bound-slug-prefixes');
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -557,10 +565,13 @@ async function rescopeClient(clientId: string, args: string[]) {
|
||||
await withConfiguredSql(async (sql) => {
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
const provider = new GBrainOAuthProvider({ sql });
|
||||
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead });
|
||||
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes });
|
||||
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
|
||||
console.log(` Write source: ${result.sourceId}`);
|
||||
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
|
||||
if (result.boundSlugPrefixes !== undefined) {
|
||||
console.log(` Bound slug prefixes: ${result.boundSlugPrefixes?.join(', ') ?? '<none — full-source write authority>'}`);
|
||||
}
|
||||
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
|
||||
});
|
||||
} catch (e: any) {
|
||||
@@ -645,14 +656,22 @@ Usage:
|
||||
--bound-tools <tool1,tool2> Bind submit_agent to an allow-list of tools
|
||||
--bound-source <id> Bind submit_agent jobs to a source id
|
||||
--bound-brain <id> Bind submit_agent jobs to a brain id
|
||||
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
|
||||
--bound-slug-prefixes <prefix1,prefix2> Fence ALL direct slug writes (put_page, delete_page,
|
||||
tags, links, timeline, revert, raw data) AND
|
||||
submit_agent to these prefixes. Each MUST end with
|
||||
'/' or '/*' — a boundary-less 'emp-alice' would also
|
||||
name 'emp-alice-2/...'. Ops that write by something
|
||||
other than a slug (extract_*, forget_fact,
|
||||
ontology_propose, sources_*) and POST /ingest become
|
||||
unavailable to a bound client. Omit = full-source writes.
|
||||
--bound-max-concurrent <n> Bound submit_agent concurrency (default: 1)
|
||||
--budget-usd-per-day <usd> Bound submit_agent daily spend cap
|
||||
gbrain auth rescope-client <client_id> [options] Change an existing client's source scope (e.g. a DCR
|
||||
client stuck on the 'default' source). Only the flags
|
||||
you pass change; the other axis is left as-is.
|
||||
you pass change; the other axes are left as-is.
|
||||
--source <id> New write source
|
||||
--federated-read <id1,id2,...> New read-scope source list
|
||||
--bound-slug-prefixes <p1,p2|none> Replace the slug-prefix write fence ('none' clears it)
|
||||
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
|
||||
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
|
||||
`);
|
||||
|
||||
@@ -1717,7 +1717,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// validator inside rescopeClient.
|
||||
app.post('/admin/api/rescope-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { clientId, sourceId, federatedRead } = req.body ?? {};
|
||||
const { clientId, sourceId, federatedRead, boundSlugPrefixes } = req.body ?? {};
|
||||
if (!clientId || typeof clientId !== 'string') {
|
||||
res.status(400).json({ error: 'clientId required' });
|
||||
return;
|
||||
@@ -1731,12 +1731,20 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
res.status(400).json({ error: 'sourceId must be a string' });
|
||||
return;
|
||||
}
|
||||
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead });
|
||||
// v0.42.72.0: tri-state write-fence rescope — omitted = untouched,
|
||||
// null = clear, array of strings = replace (mirrors the CLI's
|
||||
// --bound-slug-prefixes p1,p2|none).
|
||||
if (boundSlugPrefixes !== undefined && boundSlugPrefixes !== null &&
|
||||
!(Array.isArray(boundSlugPrefixes) && boundSlugPrefixes.every((s: unknown) => typeof s === 'string'))) {
|
||||
res.status(400).json({ error: 'boundSlugPrefixes must be null or an array of slug-prefix strings' });
|
||||
return;
|
||||
}
|
||||
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Rescope failed';
|
||||
const status = /No OAuth client found/.test(message) ? 404
|
||||
: /Invalid source_id|requires --source|cannot be empty|does not exist/.test(message) ? 400
|
||||
: /Invalid source_id|requires --source|cannot be empty|does not exist|cannot be an empty list|bound_slug_prefixes entr/.test(message) ? 400
|
||||
: 500;
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
@@ -2278,6 +2286,31 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
const sourceId = (req.header('x-gbrain-source-id') || `webhook-${authInfo.clientId}`).slice(0, 256);
|
||||
const callerSlug = req.header('x-gbrain-slug');
|
||||
|
||||
// Slug-bound clients cannot use /ingest at all. The route hands its
|
||||
// payload to the ingest_capture minion handler, which deliberately
|
||||
// bypasses the put_page op layer — so no OperationContext exists and
|
||||
// enforceClientSlugFence never runs, and because the payload is marked
|
||||
// untrusted the handler also refuses to honor any source id, landing
|
||||
// every write in the DEFAULT source. Fencing just the slug here would
|
||||
// still write the right slug into the WRONG source, outside the
|
||||
// client's grant. These clients have put_page over MCP, which enforces
|
||||
// both the prefix fence and the source scope; webhook integrations use
|
||||
// unbound clients.
|
||||
const boundPrefixes = authInfo.boundSlugPrefixes;
|
||||
if (boundPrefixes || authInfo.fenceProjectionDegraded) {
|
||||
res.status(403).json({
|
||||
error: 'permission_denied',
|
||||
message: authInfo.fenceProjectionDegraded
|
||||
? 'POST /ingest is unavailable: this brain\'s oauth_clients projection is missing ' +
|
||||
'bound_slug_prefixes, so client write bindings cannot be evaluated. ' +
|
||||
'Run `gbrain apply-migrations --yes` on the brain host.'
|
||||
: 'POST /ingest is not available to clients restricted to slug prefixes ' +
|
||||
`(bound_slug_prefixes: ${boundPrefixes!.join(', ')}). Write through the MCP put_page op, ` +
|
||||
'which enforces the prefix fence and your source scope.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const event: IngestionEvent = {
|
||||
source_id: sourceId,
|
||||
source_kind: 'webhook',
|
||||
|
||||
+173
-37
@@ -28,6 +28,38 @@ import { assertValidSourceId } from './source-id.ts';
|
||||
import { hasScope, assertAllowedScopes, parseScopeString, InvalidScopeError } from './scope.ts';
|
||||
import type { AuthInfo as CoreAuthInfo } from './operations.ts';
|
||||
import { parseLegacyTokenScope } from './legacy-token-scope.ts';
|
||||
|
||||
/**
|
||||
* A slug-prefix write binding is only meaningful if every entry actually
|
||||
* constrains something. `''` (or whitespace) matches every slug under
|
||||
* `startsWith`, so one unset variable in a provisioning template would turn
|
||||
* a binding into a silent wildcard while still displaying as "fenced".
|
||||
* Reject at every write surface: registration, rescope, admin API.
|
||||
*/
|
||||
export function assertValidSlugPrefixes(prefixes: readonly string[]): void {
|
||||
for (const p of prefixes) {
|
||||
if (typeof p !== 'string' || p.trim() === '') {
|
||||
throw new Error('bound_slug_prefixes entries must be non-empty, non-whitespace slug prefixes (e.g. "emp-alice/")');
|
||||
}
|
||||
if (p !== p.trim()) {
|
||||
throw new Error(`bound_slug_prefixes entry "${p}" has leading/trailing whitespace; slugs never do, so it would fence nothing`);
|
||||
}
|
||||
// Slugs are lowercased by validateSlug before storage, so a prefix with
|
||||
// uppercase in it cannot correspond to anything actually written.
|
||||
if (p !== p.toLowerCase()) {
|
||||
throw new Error(`bound_slug_prefixes entry "${p}" must be lowercase; stored slugs are lowercased, so a mixed-case prefix fences unpredictably`);
|
||||
}
|
||||
// Require an explicit segment boundary. Slug namespaces collide on their
|
||||
// own naming scheme — `emp-alice` and `emp-alice-2` are different people —
|
||||
// and a boundary-less entry reads as "everything starting with these
|
||||
// characters". The matcher is boundary-aware regardless, but saying it at
|
||||
// registration is what stops an operator writing a binding whose meaning
|
||||
// isn't what it looks like.
|
||||
if (!p.endsWith('/') && !p.endsWith('/*')) {
|
||||
throw new Error(`bound_slug_prefixes entry "${p}" must end with "/" (or "/*"); a boundary-less prefix reads as a character prefix, so "${p}" would look like it covers only "${p}/..." while naming sibling namespaces like "${p}-2/..."`);
|
||||
}
|
||||
}
|
||||
}
|
||||
import type { SqlQuery, SqlValue } from './sql-query.ts';
|
||||
export type { SqlQuery, SqlValue };
|
||||
|
||||
@@ -606,41 +638,61 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
try {
|
||||
oauthRows = await this.sql`
|
||||
SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name,
|
||||
c.source_id, c.federated_read
|
||||
c.source_id, c.federated_read, c.bound_slug_prefixes
|
||||
FROM oauth_tokens t
|
||||
LEFT JOIN oauth_clients c ON c.client_id = t.client_id
|
||||
WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access'
|
||||
`;
|
||||
} catch (err) {
|
||||
// v0.34.1: pre-v60 brain → source_id column missing. Pre-v61 brain →
|
||||
// federated_read column missing. Both classes degrade to legacy
|
||||
// projection so auth keeps working until the operator runs
|
||||
// apply-migrations. Probe both column names so partial-upgrade brains
|
||||
// (v60 applied but v61 didn't yet) also fall through cleanly.
|
||||
if (isUndefinedColumnError(err, 'source_id') || isUndefinedColumnError(err, 'federated_read')) {
|
||||
// Try the v60-only projection first (source_id but no federated_read).
|
||||
// Degrade ladder for brains that haven't run apply-migrations yet:
|
||||
// bound_slug_prefixes (v85) → federated_read (v61) → source_id (v60) →
|
||||
// pre-v0.34 base projection. Auth must keep working the whole way down.
|
||||
//
|
||||
// `isUndefinedColumnError(err, name)` canNOT actually tell us WHICH
|
||||
// column was missing — with SQLSTATE 42703 present it returns true for
|
||||
// any undefined column, and the name is only consulted in the message
|
||||
// fallback. So the ladder must not branch on the reported name; it
|
||||
// walks every narrower projection in turn, each guarded, and only
|
||||
// rethrows once the narrowest one still fails. (Branching on the name
|
||||
// is what made the first cut of this hard-fail every token
|
||||
// verification on a pre-v61 brain.)
|
||||
// Any of the three optional columns may be the missing one, and on the
|
||||
// message-fallback path (drivers that don't surface SQLSTATE) the name
|
||||
// is what identifies it — so probe all three at every rung.
|
||||
const missingOAuthColumn = (e: unknown): boolean =>
|
||||
isUndefinedColumnError(e, 'bound_slug_prefixes') ||
|
||||
isUndefinedColumnError(e, 'federated_read') ||
|
||||
isUndefinedColumnError(e, 'source_id');
|
||||
if (!missingOAuthColumn(err)) throw err;
|
||||
try {
|
||||
// v85 missing: keep source_id + federated_read, drop the fence column.
|
||||
oauthRows = await this.sql`
|
||||
SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name,
|
||||
c.source_id, c.federated_read
|
||||
FROM oauth_tokens t
|
||||
LEFT JOIN oauth_clients c ON c.client_id = t.client_id
|
||||
WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access'
|
||||
`;
|
||||
} catch (err2) {
|
||||
if (!missingOAuthColumn(err2)) throw err2;
|
||||
try {
|
||||
// v61 missing: source_id only.
|
||||
oauthRows = await this.sql`
|
||||
SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name, c.source_id
|
||||
FROM oauth_tokens t
|
||||
LEFT JOIN oauth_clients c ON c.client_id = t.client_id
|
||||
WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access'
|
||||
`;
|
||||
} catch (err2) {
|
||||
if (isUndefinedColumnError(err2, 'source_id')) {
|
||||
// Truly pre-v60: no source_id either. Pre-v0.34 projection.
|
||||
oauthRows = await this.sql`
|
||||
SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name
|
||||
FROM oauth_tokens t
|
||||
LEFT JOIN oauth_clients c ON c.client_id = t.client_id
|
||||
WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access'
|
||||
`;
|
||||
} else {
|
||||
throw err2;
|
||||
}
|
||||
} catch (err3) {
|
||||
if (!missingOAuthColumn(err3)) throw err3;
|
||||
// Truly pre-v60: pre-v0.34 projection.
|
||||
oauthRows = await this.sql`
|
||||
SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name
|
||||
FROM oauth_tokens t
|
||||
LEFT JOIN oauth_clients c ON c.client_id = t.client_id
|
||||
WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access'
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,9 +711,39 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// array vs undefined matters: empty array = explicit no-federated-
|
||||
// read; undefined = column missing on this brain.
|
||||
const federatedRaw = row.federated_read;
|
||||
const allowedSources = Array.isArray(federatedRaw)
|
||||
const rowSourceId = (row.source_id as string | null) ?? undefined;
|
||||
let allowedSources = Array.isArray(federatedRaw)
|
||||
? (federatedRaw as string[])
|
||||
: undefined;
|
||||
// Degraded-projection safety: `resolveRequestedScope` only authorizes an
|
||||
// explicitly requested `source_id` when `allowedSources` is a NON-EMPTY
|
||||
// array — with it undefined, a remote caller naming any source is
|
||||
// accepted. On a brain missing `federated_read` the ladder above returns
|
||||
// exactly that undefined, so a client scoped to one source could read
|
||||
// every other source by passing `source_id`. Synthesize the client's own
|
||||
// source as its grant so the authorization check stays armed. (Legacy
|
||||
// `access_tokens` keep their historical scope handling below — this only
|
||||
// covers the OAuth rows whose column we just dropped.)
|
||||
if (allowedSources === undefined && rowSourceId !== undefined) {
|
||||
allowedSources = [rowSourceId];
|
||||
}
|
||||
// v0.42.72.0: slug-prefix write binding. Array (even empty — the
|
||||
// fence treats [] as deny-all, matching submit_agent's fail-closed
|
||||
// posture) when the client carries a binding; undefined when the
|
||||
// column is NULL, the projection degraded, or the brain predates
|
||||
// the column.
|
||||
const boundRaw = row.bound_slug_prefixes;
|
||||
const boundSlugPrefixes = Array.isArray(boundRaw)
|
||||
? (boundRaw as string[])
|
||||
: undefined;
|
||||
// Fail CLOSED on the fence axis. If the projection degraded, we do not
|
||||
// know whether this client carries a binding, and "column absent" is
|
||||
// indistinguishable from "no binding" downstream. On a genuinely
|
||||
// pre-v85 brain no binding can exist and this is harmless; the case
|
||||
// that matters is a partially broken schema (interrupted migration,
|
||||
// restored dump missing one column) where bindings DO exist and every
|
||||
// bound client would otherwise be silently unfenced.
|
||||
const fenceProjectionDegraded = !('bound_slug_prefixes' in row);
|
||||
return {
|
||||
token,
|
||||
clientId: row.client_id as string,
|
||||
@@ -672,11 +754,15 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// v0.34.1 (#861, D2): source-isolation scope from oauth_clients.
|
||||
// Undefined when the row predates v60 or when the brain itself
|
||||
// predates v60 (fell through to the legacy projection above).
|
||||
sourceId: (row.source_id as string | null) ?? undefined,
|
||||
sourceId: rowSourceId,
|
||||
// v0.34.1 (#876): federated read scope. sourceScopeOpts in
|
||||
// operations.ts prefers this array over scalar sourceId when set
|
||||
// and non-empty.
|
||||
allowedSources,
|
||||
// v0.42.72.0: write fence — consumed by enforceClientSlugFence in
|
||||
// operations.ts on every direct slug-mutating write op.
|
||||
boundSlugPrefixes,
|
||||
...(fenceProjectionDegraded ? { fenceProjectionDegraded: true } : {}),
|
||||
} as CoreAuthInfo as SdkAuthInfo;
|
||||
}
|
||||
|
||||
@@ -903,6 +989,20 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// existing rows aren't re-validated).
|
||||
assertAllowedScopes(parseScopeString(scopes));
|
||||
|
||||
// A bound_slug_prefixes entry that is empty or whitespace-only makes
|
||||
// `startsWith` true for every slug — a binding that looks set in
|
||||
// `auth list` and the admin UI while fencing nothing. Reject at
|
||||
// registration, the same way source ids are validated.
|
||||
if (agentBindings?.boundSlugPrefixes) {
|
||||
// Same rule as rescopeClient: an empty list is ambiguous. It registers
|
||||
// as deny-all for every direct write while printing an empty binding
|
||||
// line, so an operator cannot tell it from an unbound client.
|
||||
if (agentBindings.boundSlugPrefixes.length === 0) {
|
||||
throw new Error('--bound-slug-prefixes cannot be an empty list (pass prefixes, or omit the flag for full-source write authority)');
|
||||
}
|
||||
assertValidSlugPrefixes(agentBindings.boundSlugPrefixes);
|
||||
}
|
||||
|
||||
// v0.41.3 (T1+T2): validate token_endpoint_auth_method at the registration
|
||||
// boundary. Throws InvalidTokenEndpointAuthMethodError on bad input.
|
||||
// Default is `client_secret_post` (RFC 7591 §2).
|
||||
@@ -1022,11 +1122,11 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
*/
|
||||
async rescopeClient(
|
||||
clientId: string,
|
||||
opts: { sourceId?: string; federatedRead?: string[] },
|
||||
): Promise<{ clientId: string; clientName: string; sourceId: string; federatedRead: string[] }> {
|
||||
const { sourceId, federatedRead } = opts;
|
||||
if (sourceId === undefined && federatedRead === undefined) {
|
||||
throw new Error('rescope-client requires --source and/or --federated-read');
|
||||
opts: { sourceId?: string; federatedRead?: string[]; boundSlugPrefixes?: string[] | null },
|
||||
): Promise<{ clientId: string; clientName: string; sourceId: string; federatedRead: string[]; boundSlugPrefixes?: string[] | null }> {
|
||||
const { sourceId, federatedRead, boundSlugPrefixes } = opts;
|
||||
if (sourceId === undefined && federatedRead === undefined && boundSlugPrefixes === undefined) {
|
||||
throw new Error('rescope-client requires --source, --federated-read, and/or --bound-slug-prefixes');
|
||||
}
|
||||
if (sourceId !== undefined) assertValidSourceId(sourceId);
|
||||
if (federatedRead !== undefined) {
|
||||
@@ -1035,17 +1135,48 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
}
|
||||
for (const s of federatedRead) assertValidSourceId(s);
|
||||
}
|
||||
// v0.42.72.0: bound_slug_prefixes rescope, so channel-membership churn
|
||||
// (the qm-harness roster case) updates the write fence in place instead
|
||||
// of forcing a register+rotate cycle. Tri-state: undefined = untouched,
|
||||
// null = clear the binding (client returns to unbound full-source write
|
||||
// authority), non-empty array = replace. Empty array is rejected here —
|
||||
// it means deny-all at the fence, which an operator should express by
|
||||
// revoking write scope, not by an ambiguous empty list.
|
||||
if (Array.isArray(boundSlugPrefixes)) {
|
||||
if (boundSlugPrefixes.length === 0) {
|
||||
throw new Error('--bound-slug-prefixes cannot be an empty list (pass prefixes, or "none" to clear the binding)');
|
||||
}
|
||||
assertValidSlugPrefixes(boundSlugPrefixes);
|
||||
}
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = await this.sql`
|
||||
UPDATE oauth_clients
|
||||
SET source_id = COALESCE(${sourceId ?? null}::text, source_id),
|
||||
federated_read = COALESCE(${federatedRead ? pgArray(federatedRead) : null}::text[], federated_read)
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING client_id, client_name, source_id, federated_read
|
||||
`;
|
||||
// Only touch bound_slug_prefixes when the caller actually passed it.
|
||||
// Naming the column unconditionally would make a plain
|
||||
// `rescope-client --source wiki` fail on a brain that has the v60/v61
|
||||
// OAuth columns but not v85's bound_* set — a regression on an axis
|
||||
// the caller never asked about.
|
||||
rows = boundSlugPrefixes === undefined
|
||||
? await this.sql`
|
||||
UPDATE oauth_clients
|
||||
SET source_id = COALESCE(${sourceId ?? null}::text, source_id),
|
||||
federated_read = COALESCE(${federatedRead ? pgArray(federatedRead) : null}::text[], federated_read)
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING client_id, client_name, source_id, federated_read
|
||||
`
|
||||
: await this.sql`
|
||||
UPDATE oauth_clients
|
||||
SET source_id = COALESCE(${sourceId ?? null}::text, source_id),
|
||||
federated_read = COALESCE(${federatedRead ? pgArray(federatedRead) : null}::text[], federated_read),
|
||||
bound_slug_prefixes = ${boundSlugPrefixes ? pgArray(boundSlugPrefixes) : null}::text[]
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING client_id, client_name, source_id, federated_read, bound_slug_prefixes
|
||||
`;
|
||||
} catch (err) {
|
||||
if (isUndefinedColumnError(err, 'source_id') || isUndefinedColumnError(err, 'federated_read')) {
|
||||
if (
|
||||
isUndefinedColumnError(err, 'source_id') ||
|
||||
isUndefinedColumnError(err, 'federated_read') ||
|
||||
isUndefinedColumnError(err, 'bound_slug_prefixes')
|
||||
) {
|
||||
throw new Error('rescope-client requires an up-to-date OAuth schema; run `gbrain apply-migrations --yes` and retry.');
|
||||
}
|
||||
// FK oauth_clients.source_id → sources(id): translate the raw 23503
|
||||
@@ -1064,6 +1195,11 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
clientName: (row.client_name as string | null) ?? '',
|
||||
sourceId: (row.source_id as string | null) ?? 'default',
|
||||
federatedRead: Array.isArray(row.federated_read) ? (row.federated_read as string[]) : [],
|
||||
// undefined = the column wasn't read this call (caller left the
|
||||
// binding untouched), which is distinct from null = no binding set.
|
||||
boundSlugPrefixes: 'bound_slug_prefixes' in row
|
||||
? (Array.isArray(row.bound_slug_prefixes) ? (row.bound_slug_prefixes as string[]) : null)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+288
-5
@@ -229,6 +229,148 @@ function enforceSubagentSlugFence(ctx: OperationContext, slug: string, opName: s
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-client slug-fence enforcement (v0.42.72.0 — write-side isolation
|
||||
* symmetry). When the authenticated client was registered with
|
||||
* --bound-slug-prefixes, every direct slug-mutating write must target a
|
||||
* slug under one of those prefixes. Shared by put_page, delete_page,
|
||||
* restore_page, add_tag, remove_tag, add_link/remove_link (`from`
|
||||
* endpoint), add_timeline_entry, revert_version, and put_raw_data; runs
|
||||
* BEFORE each op's dry-run short-circuit so preview calls surface the
|
||||
* same rejection.
|
||||
*
|
||||
* Semantics deliberately match submit_agent's bound_slug_prefixes check
|
||||
* (plain startsWith, NOT the `/*` glob grammar of the subagent allow-list
|
||||
* above): a non-null binding fences fail-closed (empty array = deny all
|
||||
* writes), no binding / no auth = no fence (local CLI and unbound clients
|
||||
* keep full-source write authority). Register prefixes with a trailing
|
||||
* slash ('wiki/agents/alice/') — a bare 'notes' also admits
|
||||
* 'notes-archive/...' by startsWith construction.
|
||||
*/
|
||||
function enforceClientSlugFence(ctx: OperationContext, slug: string, opName: string): void {
|
||||
if (ctx.auth?.fenceProjectionDegraded) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`${opName}: this brain's oauth_clients projection is missing bound_slug_prefixes, so the write fence cannot be evaluated. Refusing the write rather than running unfenced.`,
|
||||
'Run `gbrain apply-migrations --yes` on the brain host.',
|
||||
);
|
||||
}
|
||||
const prefixes = ctx.auth?.boundSlugPrefixes;
|
||||
if (!prefixes) return;
|
||||
if (!slugUnderBoundPrefixes(prefixes, slug)) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`${opName}: slug '${slug}' is not under any of client ${ctx.auth?.clientId ?? '(unknown)'}'s bound_slug_prefixes (${prefixes.join(', ')})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place the fence's match rule lives. Exported so non-op write
|
||||
* surfaces that never build an OperationContext (the `/ingest` route in
|
||||
* serve-http.ts) enforce byte-identical semantics instead of re-deriving
|
||||
* them.
|
||||
*
|
||||
* An empty-string prefix is IGNORED rather than honored: `startsWith('')`
|
||||
* is true for every slug, so a stray `''` (an unset shell variable in a
|
||||
* provisioning template) would silently turn a binding into a wildcard
|
||||
* while still rendering as "fenced" to the operator. Registration now
|
||||
* rejects empty prefixes outright; this is the second line of defence for
|
||||
* rows already in the database.
|
||||
*/
|
||||
export function slugUnderBoundPrefixes(prefixes: readonly string[], slug: string): boolean {
|
||||
// Compare against the CANONICAL slug. `validateSlug` lowercases before the
|
||||
// row is written, so checking the caller's raw string let `EMP-ALICE/x`
|
||||
// satisfy an `EMP-ALICE/` binding, commit as `emp-alice/x`, and only then
|
||||
// trip the resolved-slug re-check — an error returned after the write had
|
||||
// already landed. Registration rejects non-lowercase prefixes going
|
||||
// forward; lowercasing both sides keeps pre-existing rows meaning what
|
||||
// their operator intended.
|
||||
const canonical = slug.toLowerCase();
|
||||
return prefixes.some((bp) => {
|
||||
const base = normalizeSlugPrefix(bp);
|
||||
if (base === '') return false;
|
||||
// Boundary-aware: a prefix must match whole SEGMENTS. Plain `startsWith`
|
||||
// let a boundary-less `emp-alice` admit `emp-alice-2/onboarding` — and
|
||||
// with the `emp-<slug>` naming this guide recommends, sibling collisions
|
||||
// (`alice` vs `alice-2`) are the common case, not a corner case.
|
||||
return base.endsWith('/')
|
||||
? canonical.startsWith(base)
|
||||
: canonical === base || canonical.startsWith(`${base}/`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of one stored prefix, lowercased. `oauth_clients.bound_slug_prefixes`
|
||||
* predates this fence — migration v85 introduced it as submit_agent's binding,
|
||||
* whose grammar is the `<prefix>/*` glob of `matchesSlugAllowList` — so both
|
||||
* spellings have to mean the same span of slugs or upgrading silently changes
|
||||
* what an existing client may write.
|
||||
*/
|
||||
export function normalizeSlugPrefix(prefix: string): string {
|
||||
return (prefix.endsWith('/*') ? prefix.slice(0, -1) : prefix).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write ops a slug-bound client may call: every op that routes through
|
||||
* `enforceClientSlugFence`, plus `think` (scope `write`, but remote callers
|
||||
* cannot persist — `save`/`take` are forced false for `remote !== false`).
|
||||
*
|
||||
* This list is an ALLOW-list on purpose. The fence used to be enforced op
|
||||
* by op, which made every unfenced write op a silent hole — `extract_entities`
|
||||
* mutating `people/*` timelines, `forget_fact` rewriting another source's
|
||||
* page by numeric id, `extract_facts` appending to any entity's fact fence.
|
||||
* Enumerating what is SAFE fails closed instead: a write op added later is
|
||||
* denied to bound clients until someone fences it and adds it here.
|
||||
*/
|
||||
export const CLIENT_FENCED_WRITE_OPS: ReadonlySet<string> = new Set([
|
||||
'put_page', 'delete_page', 'restore_page', 'add_tag', 'remove_tag',
|
||||
'add_link', 'remove_link', 'add_timeline_entry', 'revert_version',
|
||||
'put_raw_data', 'think',
|
||||
// submit_agent enforces bound_slug_prefixes itself (it is the op the column
|
||||
// was introduced for — see its bound_* binding check), so denying it here
|
||||
// would break the original feature for clients that legitimately hold both
|
||||
// a binding and `agent` scope.
|
||||
'submit_agent',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fail-closed gate for slug-bound clients, applied at dispatch (the single
|
||||
* choke point both MCP transports share) so it cannot be forgotten per op.
|
||||
* Read ops are untouched — read scope is enforced by source federation.
|
||||
*/
|
||||
export function enforceBoundClientOpAllowList(
|
||||
auth: AuthInfo | undefined,
|
||||
op: Pick<Operation, 'name' | 'scope' | 'mutating'>,
|
||||
): void {
|
||||
// A degraded projection means we could not read the binding, not that
|
||||
// there isn't one. Deny every non-read op outright — otherwise the
|
||||
// unfenceable ops stay reachable precisely when the fence is unreadable.
|
||||
const degraded = auth?.fenceProjectionDegraded === true;
|
||||
if (!degraded && !auth?.boundSlugPrefixes) return;
|
||||
// Gate on "mutates, or carries any non-read scope" rather than on the two
|
||||
// literal scope strings 'write'/'admin': `sources_add` / `sources_remove`
|
||||
// carry the bespoke `sources_admin` scope and are `mutating: true`, so a
|
||||
// scope-string check let a bound client DROP AN ENTIRE SOURCE — every page
|
||||
// in it, far outside any prefix. Anything that isn't a plain read must be
|
||||
// explicitly allow-listed.
|
||||
const isRead = op.scope === 'read' && op.mutating !== true;
|
||||
if (isRead) return;
|
||||
if (degraded) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`${op.name}: this brain's oauth_clients projection is missing bound_slug_prefixes, so client write bindings cannot be evaluated. Refusing every non-read operation rather than running unfenced.`,
|
||||
'Run `gbrain apply-migrations --yes` on the brain host.',
|
||||
);
|
||||
}
|
||||
if (CLIENT_FENCED_WRITE_OPS.has(op.name)) return;
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`${op.name} is not available to slug-bound clients: it can write outside client ${auth?.clientId ?? '(unknown)'}'s bound_slug_prefixes (${(auth?.boundSlugPrefixes ?? []).join(', ')}).`,
|
||||
'Use put_page / add_timeline_entry / add_link under your own prefixes, or ask an operator to clear the binding with `gbrain auth rescope-client <id> --bound-slug-prefixes none`.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist validator for uploaded file basenames. Rejects control chars, backslashes,
|
||||
* RTL overrides (\u202E), leading dot (hidden files) and leading dash (CLI flag confusion).
|
||||
@@ -308,6 +450,31 @@ export interface AuthInfo {
|
||||
* case (back-compat).
|
||||
*/
|
||||
allowedSources?: string[];
|
||||
/**
|
||||
* v0.42.72.0: slug-prefix WRITE binding from
|
||||
* `oauth_clients.bound_slug_prefixes`, threaded at token-verification
|
||||
* time (same JOIN as sourceId/allowedSources — no per-op roundtrip).
|
||||
* When present, every direct slug-mutating write op is fenced to slugs
|
||||
* under one of these prefixes via `enforceClientSlugFence` — the same
|
||||
* plain-startsWith semantics (and the same fail-closed empty-array
|
||||
* posture) as submit_agent's bound_slug_prefixes check, so one column
|
||||
* means one thing everywhere it's read. Closes the write-side half of
|
||||
* shared-source isolation: reads were SQL-fenced via `allowedSources`,
|
||||
* but same-source writes were folder-convention-only.
|
||||
*
|
||||
* Undefined = client has no binding, or the brain predates the
|
||||
* bound_slug_prefixes column → no fence (unbound clients keep
|
||||
* full-source write authority, back-compat).
|
||||
*/
|
||||
boundSlugPrefixes?: string[];
|
||||
/**
|
||||
* Set when token verification could not read `bound_slug_prefixes` (the
|
||||
* projection degraded on a brain missing an OAuth column). The fence can't
|
||||
* distinguish "no binding" from "binding unknown" otherwise, so writes are
|
||||
* refused rather than silently unfenced. Read/auth degradation is
|
||||
* unaffected — this axis alone fails closed.
|
||||
*/
|
||||
fenceProjectionDegraded?: boolean;
|
||||
}
|
||||
|
||||
export interface OperationContext {
|
||||
@@ -904,6 +1071,7 @@ const put_page: Operation = {
|
||||
// short-circuit so preview calls surface the same rejection. See
|
||||
// enforceSubagentSlugFence for the fail-closed policy.
|
||||
enforceSubagentSlugFence(ctx, slug, 'put_page');
|
||||
enforceClientSlugFence(ctx, slug, 'put_page');
|
||||
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug };
|
||||
|
||||
@@ -978,6 +1146,29 @@ const put_page: Operation = {
|
||||
ingested_via: provenanceVia,
|
||||
});
|
||||
|
||||
// The dedup pre-check in importFromContent can resolve the write to a
|
||||
// DIFFERENT page than the one requested (same content_hash, or the same
|
||||
// `frontmatter.id`), and the disk write-through below runs against that
|
||||
// RESOLVED slug. Fence it too: a bound client can read a victim page's
|
||||
// frontmatter id over its federated grant, echo it back in an in-prefix
|
||||
// put_page, and otherwise have write-through rewrite the victim's file
|
||||
// with falsified provenance. Dedup returns status 'skipped' without
|
||||
// touching the DB, so throwing here leaves nothing to roll back.
|
||||
if (result.slug && result.slug !== slug) {
|
||||
// Deliberately does NOT name the resolved slug: it belongs to a page
|
||||
// outside the binding, and echoing it would turn frontmatter-id guessing
|
||||
// into a slug-enumeration oracle.
|
||||
if (!slugUnderBoundPrefixes(ctx.auth?.boundSlugPrefixes ?? [], result.slug)
|
||||
&& ctx.auth?.boundSlugPrefixes) {
|
||||
ctx.logger.warn(`[put_page] dedup resolved '${slug}' to an out-of-fence page; refusing (client ${ctx.auth.clientId ?? 'unknown'})`);
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`put_page: this content already exists on a page outside your bound_slug_prefixes, so the write would have modified that page instead.`,
|
||||
'Remove the `id:` frontmatter field (or change the content) to write a new page under your own prefix.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.39 T13 — auto-prompt on first unknown-type write.
|
||||
//
|
||||
// Contract (codex finding #8 honored — 7 cases covered):
|
||||
@@ -1127,6 +1318,22 @@ const put_page: Operation = {
|
||||
// (MEDIUM facts wait for the dream cycle but DO land via put_page,
|
||||
// matching the pre-fix behavior on this surface).
|
||||
let factsQueued: { queued: boolean } | { skipped: string } | undefined;
|
||||
// Slug-bound clients do not get the facts backstop. It extracts entities
|
||||
// from the (attacker-controllable) page body and writes fact rows — and,
|
||||
// on a source with a local_path, a `## Facts` fence in the entity's own
|
||||
// .md — keyed to `people/…` / `companies/…` slugs the caller never named.
|
||||
// That is exactly the capability `extract_facts` is denied at dispatch
|
||||
// for, reachable indirectly through a perfectly in-prefix put_page. The
|
||||
// sibling post-hooks above already skip for untrusted callers (auto-link
|
||||
// at `remote !== false && !trustedWorkspace`, chronicle at
|
||||
// `remote !== false`); this one had no gate at all.
|
||||
// Keyed on "the caller is slug-confined at all", not on ctx.auth alone:
|
||||
// the delegated (submit_agent → subagent) context carries
|
||||
// `allowedSlugPrefixes` but NOT `auth`, so an auth-only test would let a
|
||||
// bound client re-open this path simply by delegating the write.
|
||||
if (ctx.auth?.boundSlugPrefixes || ctx.viaSubagent === true) {
|
||||
factsQueued = { skipped: 'slug_bound_client' };
|
||||
} else {
|
||||
try {
|
||||
const { runFactsBackstop } = await import('./facts/backstop.ts');
|
||||
const r = await runFactsBackstop(
|
||||
@@ -1159,6 +1366,7 @@ const put_page: Operation = {
|
||||
} catch {
|
||||
factsQueued = { skipped: 'backstop_error' };
|
||||
}
|
||||
}
|
||||
|
||||
// v0.42.x (#2390): Life Chronicle backstop. ONLY on a real import
|
||||
// (status==='imported' — a skipped/unchanged rewrite still carries
|
||||
@@ -1421,6 +1629,7 @@ const delete_page: Operation = {
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
const slug = p.slug as string;
|
||||
enforceClientSlugFence(ctx, slug, 'delete_page');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'soft_delete_page', slug };
|
||||
// v0.31.8 (D7): thread ctx.sourceId so multi-source brains soft-delete the
|
||||
// intended row instead of always targeting (default, slug).
|
||||
@@ -1454,6 +1663,7 @@ const restore_page: Operation = {
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
const slug = p.slug as string;
|
||||
enforceClientSlugFence(ctx, slug, 'restore_page');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'restore_page', slug };
|
||||
// v0.31.8 (D7): thread ctx.sourceId.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
@@ -2097,6 +2307,7 @@ const add_tag: Operation = {
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
enforceClientSlugFence(ctx, p.slug as string, 'add_tag');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'add_tag', slug: p.slug, tag: p.tag };
|
||||
// v0.31.8 (D7): thread ctx.sourceId.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
@@ -2116,6 +2327,7 @@ const remove_tag: Operation = {
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
enforceClientSlugFence(ctx, p.slug as string, 'remove_tag');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'remove_tag', slug: p.slug, tag: p.tag };
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
await ctx.engine.removeTag(p.slug as string, p.tag as string, sourceOpts);
|
||||
@@ -2169,6 +2381,10 @@ const add_link: Operation = {
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
// Client fence on the `from` endpoint only: the edge originates from
|
||||
// (and renders on) the from page; linking TO a page outside the
|
||||
// binding is a reference, not a mutation of the target.
|
||||
enforceClientSlugFence(ctx, p.from as string, 'add_link');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to };
|
||||
// v114 (#1941): default omitted provenance to 'manual' (NOT the engine's
|
||||
// 'markdown' default) so hand/tool-created CLI edges are honestly manual,
|
||||
@@ -2209,6 +2425,7 @@ const remove_link: Operation = {
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
enforceClientSlugFence(ctx, p.from as string, 'remove_link');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'remove_link', from: p.from, to: p.to };
|
||||
const linkOpts = ctx.sourceId
|
||||
? { fromSourceId: ctx.sourceId, toSourceId: ctx.sourceId }
|
||||
@@ -2336,6 +2553,7 @@ const add_timeline_entry: Operation = {
|
||||
// confined to the same namespace/allow-list as page writes. Runs before
|
||||
// the dry-run short-circuit so preview calls surface the same rejection.
|
||||
enforceSubagentSlugFence(ctx, p.slug as string, 'add_timeline_entry');
|
||||
enforceClientSlugFence(ctx, p.slug as string, 'add_timeline_entry');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
|
||||
const date = p.date as string;
|
||||
// Reject anything that isn't a strict YYYY-MM-DD with year 1900-2199 and
|
||||
@@ -2730,6 +2948,7 @@ const revert_version: Operation = {
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
enforceClientSlugFence(ctx, p.slug as string, 'revert_version');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'revert_version', slug: p.slug, version_id: p.version_id };
|
||||
// v0.31.8 (D7): thread ctx.sourceId so multi-source brains revert the
|
||||
// intended page row instead of whichever same-slug row Postgres returns
|
||||
@@ -2789,6 +3008,7 @@ const put_raw_data: Operation = {
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
enforceClientSlugFence(ctx, p.slug as string, 'put_raw_data');
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'put_raw_data', slug: p.slug, source: p.source };
|
||||
// v0.31.8 (D7 + D21): thread ctx.sourceId.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
@@ -3189,7 +3409,20 @@ const submit_agent: Operation = {
|
||||
}
|
||||
|
||||
// Validate each param against the binding.
|
||||
const requestedTools = (p.allowed_tools as string[] | undefined) ?? boundTools;
|
||||
//
|
||||
// An EXPLICIT empty array is not "no restriction" here — downstream the
|
||||
// subagent worker reads empty `allowed_tools` as "the full tool registry"
|
||||
// and empty `allowed_slug_prefixes` as "fall back to the legacy
|
||||
// wiki/agents/<job-id>/ namespace". Both subset loops below pass
|
||||
// vacuously over an empty list, so `{allowed_tools: [], allowed_slug_prefixes: []}`
|
||||
// from a client bound to `['search']` + `['emp-alice/']` would hand its
|
||||
// subagent the whole registry (including put_page) writing outside the
|
||||
// binding. `??` only substitutes null/undefined, so collapse the empty
|
||||
// case to the binding explicitly.
|
||||
const requestedToolsRaw = p.allowed_tools as string[] | undefined;
|
||||
const requestedTools = requestedToolsRaw === undefined || requestedToolsRaw.length === 0
|
||||
? boundTools
|
||||
: requestedToolsRaw;
|
||||
for (const t of requestedTools) {
|
||||
if (!boundTools.includes(t)) {
|
||||
throw new OperationError(
|
||||
@@ -3198,10 +3431,35 @@ const submit_agent: Operation = {
|
||||
);
|
||||
}
|
||||
}
|
||||
const requestedSlugPrefixes = (p.allowed_slug_prefixes as string[] | undefined) ?? boundSlugPrefixes ?? [];
|
||||
const requestedSlugPrefixesRaw = p.allowed_slug_prefixes as string[] | undefined;
|
||||
const requestedSlugPrefixes =
|
||||
requestedSlugPrefixesRaw === undefined || requestedSlugPrefixesRaw.length === 0
|
||||
? (boundSlugPrefixes ?? [])
|
||||
: requestedSlugPrefixesRaw;
|
||||
// A bound client must end up with a non-empty delegated fence: an empty
|
||||
// list reaches the subagent as "use the legacy wiki/agents/<id>/ namespace",
|
||||
// which is outside every bound prefix.
|
||||
if (boundSlugPrefixes !== null && requestedSlugPrefixes.length === 0) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`submit_agent: client ${clientId} is slug-bound but its binding resolved to an empty prefix list, which the subagent would read as the unfenced legacy namespace.`,
|
||||
'Re-scope the client with a non-empty --bound-slug-prefixes.',
|
||||
);
|
||||
}
|
||||
if (boundSlugPrefixes !== null) {
|
||||
for (const sp of requestedSlugPrefixes) {
|
||||
if (!boundSlugPrefixes.some(bp => sp.startsWith(bp) || bp === sp)) {
|
||||
// Boundary-aware, same rule as the direct fence: a raw `startsWith`
|
||||
// let a boundary-less binding (`emp-alice`) authorize a requested
|
||||
// prefix in a SIBLING namespace (`emp-alice-2/`), which is then handed
|
||||
// to the child as a full glob grant over another employee's pages.
|
||||
if (!boundSlugPrefixes.some(bp => {
|
||||
const base = normalizeSlugPrefix(bp);
|
||||
const req = normalizeSlugPrefix(sp);
|
||||
if (base === '') return false;
|
||||
return base.endsWith('/')
|
||||
? req.startsWith(base)
|
||||
: req === base || req.startsWith(`${base}/`);
|
||||
})) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`submit_agent: slug_prefix "${sp}" is not under any of client ${clientId}'s bound_slug_prefixes.`,
|
||||
@@ -3227,6 +3485,14 @@ const submit_agent: Operation = {
|
||||
}
|
||||
|
||||
// Dry-run echo.
|
||||
// The subagent fence uses `matchesSlugAllowList`, whose grammar makes a
|
||||
// BARE entry match that one slug exactly — so a plain `emp-alice/` binding
|
||||
// would let the delegated agent write nothing. Normalize the
|
||||
// trailing-slash form into the glob the delegated matcher expects, so one
|
||||
// stored column means the same span of slugs on both paths.
|
||||
const delegatedSlugPrefixes = requestedSlugPrefixes.map(sp =>
|
||||
sp.endsWith('/') ? `${sp}*` : sp);
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return {
|
||||
dry_run: true,
|
||||
@@ -3235,6 +3501,10 @@ const submit_agent: Operation = {
|
||||
bound_tools: boundTools,
|
||||
bound_source: boundSource,
|
||||
bound_max_concurrent: boundMaxConcurrent,
|
||||
// What the delegated job would ACTUALLY be granted, after the binding
|
||||
// is applied — a preview that hides this can't show a widening bug.
|
||||
resolved_tools: requestedTools,
|
||||
resolved_slug_prefixes: delegatedSlugPrefixes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3248,11 +3518,24 @@ const submit_agent: Operation = {
|
||||
prompt: p.prompt as string,
|
||||
max_turns: Math.min((p.max_turns as number) ?? 20, 100),
|
||||
allowed_tools: requestedTools,
|
||||
allowed_slug_prefixes: requestedSlugPrefixes,
|
||||
allowed_slug_prefixes: delegatedSlugPrefixes,
|
||||
__owner_client_id: clientId,
|
||||
};
|
||||
if (typeof p.model === 'string') jobData.model = p.model;
|
||||
if (boundSource) jobData.source_id = boundSource;
|
||||
// Write source for the delegated job comes from the AUTHENTICATED client
|
||||
// whenever we have it. `bound_source_id` is an optional, separately-set
|
||||
// column: unset it defaulted the child to 'default', and if it disagreed
|
||||
// with the token's own source the child followed the column — either way
|
||||
// a correctly slug-fenced client could act on the wrong source.
|
||||
const delegatedSource = ctx.auth?.sourceId ?? boundSource;
|
||||
if (boundSource && ctx.auth?.sourceId && boundSource !== ctx.auth.sourceId) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
`submit_agent: client ${clientId}'s bound_source_id (${boundSource}) disagrees with its authenticated source (${ctx.auth.sourceId}); refusing to guess which one governs the delegated write.`,
|
||||
'Re-scope the client so the two agree: `gbrain auth rescope-client <id> --source <source>`.',
|
||||
);
|
||||
}
|
||||
if (delegatedSource) jobData.source_id = delegatedSource;
|
||||
const job = await queue.add(
|
||||
'subagent',
|
||||
jobData,
|
||||
|
||||
+6
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { operations, OperationError } from '../core/operations.ts';
|
||||
import { operations, OperationError, enforceBoundClientOpAllowList } from '../core/operations.ts';
|
||||
import type { Operation, OperationContext, AuthInfo } from '../core/operations.ts';
|
||||
import { loadConfig } from '../core/config.ts';
|
||||
|
||||
@@ -280,6 +280,11 @@ export async function dispatchToolCall(
|
||||
const ctx = buildOperationContext(engine, safeParams, opts);
|
||||
|
||||
try {
|
||||
// Fail-closed gate for slug-bound OAuth clients, applied here because
|
||||
// this is the one path both MCP transports share. Per-op fences still
|
||||
// run inside the handlers; this stops an unfenced write op from being
|
||||
// a silent hole. See CLIENT_FENCED_WRITE_OPS in operations.ts.
|
||||
enforceBoundClientOpAllowList(ctx.auth, op);
|
||||
const result = await op.handler(ctx, safeParams);
|
||||
const out: ToolResult = { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||
// v0.31 (eD3 + eE4): best-effort _meta.brain_hot_memory injection.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* OAuth-client slug-fence tests (v0.42.70.0 — write-side isolation symmetry).
|
||||
*
|
||||
* enforceClientSlugFence confines a bound client's direct writes to slugs
|
||||
* under its `oauth_clients.bound_slug_prefixes`. This pins:
|
||||
* - regression: no auth / unbound client → every op accepts any slug
|
||||
* (local CLI and unbound-remote behavior unchanged);
|
||||
* - fence: each slug-mutating write op rejects out-of-binding slugs with
|
||||
* permission_denied, BEFORE the dry-run short-circuit (all denials here
|
||||
* run with dryRun=true and an empty engine stub);
|
||||
* - fail-closed: an empty-array binding denies all writes (matches
|
||||
* submit_agent's posture for the same column);
|
||||
* - add_link/remove_link fence the `from` endpoint only — linking TO a
|
||||
* page outside the binding is a reference, not a mutation of it.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
operations, OperationError, slugUnderBoundPrefixes,
|
||||
enforceBoundClientOpAllowList, CLIENT_FENCED_WRITE_OPS,
|
||||
} from '../src/core/operations.ts';
|
||||
import type { OperationContext, Operation, AuthInfo } from '../src/core/operations.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
function op(name: string): Operation {
|
||||
const found = operations.find(o => o.name === name);
|
||||
if (!found) throw new Error(`${name} op missing`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
const engine = {} as BrainEngine; // dry_run short-circuits before touching the engine
|
||||
return {
|
||||
engine,
|
||||
config: { engine: 'postgres' } as any,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
dryRun: true,
|
||||
remote: true,
|
||||
sourceId: 'shared',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function boundAuth(prefixes: string[] | undefined): AuthInfo {
|
||||
return {
|
||||
token: 'test-token',
|
||||
clientId: 'gbrain_cl_fence_test',
|
||||
scopes: ['read', 'write'],
|
||||
sourceId: 'shared',
|
||||
...(prefixes !== undefined ? { boundSlugPrefixes: prefixes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Every fenced op with a params factory for an arbitrary slug.
|
||||
const FENCED_OPS: Array<{ name: string; params: (slug: string) => Record<string, unknown> }> = [
|
||||
{ name: 'put_page', params: (slug) => ({ slug, content: 'stub' }) },
|
||||
{ name: 'delete_page', params: (slug) => ({ slug }) },
|
||||
{ name: 'restore_page', params: (slug) => ({ slug }) },
|
||||
{ name: 'add_tag', params: (slug) => ({ slug, tag: 't' }) },
|
||||
{ name: 'remove_tag', params: (slug) => ({ slug, tag: 't' }) },
|
||||
{ name: 'add_link', params: (slug) => ({ from: slug, to: 'org-wiki/roadmap' }) },
|
||||
{ name: 'remove_link', params: (slug) => ({ from: slug, to: 'org-wiki/roadmap' }) },
|
||||
{ name: 'add_timeline_entry', params: (slug) => ({ slug, date: '2026-08-01', summary: 's' }) },
|
||||
{ name: 'revert_version', params: (slug) => ({ slug, version_id: 1 }) },
|
||||
{ name: 'put_raw_data', params: (slug) => ({ slug, source: 'src', data: {} }) },
|
||||
];
|
||||
|
||||
describe('client slug fence (bound_slug_prefixes on direct writes)', () => {
|
||||
describe('regression: unbound callers unchanged', () => {
|
||||
for (const { name, params } of FENCED_OPS) {
|
||||
test(`${name}: no ctx.auth accepts arbitrary slug`, async () => {
|
||||
const result = await op(name).handler(makeCtx(), params('anywhere/at-all'));
|
||||
expect(result).toMatchObject({ dry_run: true });
|
||||
});
|
||||
|
||||
test(`${name}: authed client WITHOUT binding accepts arbitrary slug`, async () => {
|
||||
const ctx = makeCtx({ auth: boundAuth(undefined) });
|
||||
const result = await op(name).handler(ctx, params('anywhere/at-all'));
|
||||
expect(result).toMatchObject({ dry_run: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('fence: bound client confined to its prefixes', () => {
|
||||
const auth = boundAuth(['chan-eng/', 'emp-alice/']);
|
||||
|
||||
for (const { name, params } of FENCED_OPS) {
|
||||
test(`${name}: in-binding slug accepted`, async () => {
|
||||
const ctx = makeCtx({ auth });
|
||||
const result = await op(name).handler(ctx, params('chan-eng/standup-notes'));
|
||||
expect(result).toMatchObject({ dry_run: true });
|
||||
});
|
||||
|
||||
test(`${name}: out-of-binding slug rejected with permission_denied`, async () => {
|
||||
const ctx = makeCtx({ auth });
|
||||
try {
|
||||
await op(name).handler(ctx, params('chan-product/roadmap'));
|
||||
throw new Error('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(OperationError);
|
||||
expect((e as OperationError).code).toBe('permission_denied');
|
||||
expect((e as Error).message).toContain('bound_slug_prefixes');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('second prefix also admits writes', async () => {
|
||||
const ctx = makeCtx({ auth });
|
||||
const result = await op('put_page').handler(ctx, { slug: 'emp-alice/journal', content: 'stub' });
|
||||
expect(result).toMatchObject({ dry_run: true });
|
||||
});
|
||||
|
||||
test('prefix match is plain startsWith — bare slug equal to a prefix-less-slash is rejected', async () => {
|
||||
const ctx = makeCtx({ auth });
|
||||
const p = op('put_page').handler(ctx, { slug: 'chan-eng', content: 'stub' });
|
||||
await expect(p).rejects.toBeInstanceOf(OperationError);
|
||||
});
|
||||
|
||||
test('add_link: `to` outside the binding is allowed (reference, not mutation)', async () => {
|
||||
const ctx = makeCtx({ auth });
|
||||
const result = await op('add_link').handler(ctx, { from: 'chan-eng/decision', to: 'org-wiki/anything' });
|
||||
expect(result).toMatchObject({ dry_run: true });
|
||||
});
|
||||
|
||||
test('local CLI (no auth, remote=false) is never fenced', async () => {
|
||||
const ctx = makeCtx({ remote: false });
|
||||
const result = await op('put_page').handler(ctx, { slug: 'people/alice', content: 'stub' });
|
||||
expect(result).toMatchObject({ dry_run: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed: empty-array binding denies all writes', () => {
|
||||
test('put_page with boundSlugPrefixes=[] rejects every slug', async () => {
|
||||
const ctx = makeCtx({ auth: boundAuth([]) });
|
||||
const p = op('put_page').handler(ctx, { slug: 'anywhere/at-all', content: 'stub' });
|
||||
await expect(p).rejects.toBeInstanceOf(OperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty-string prefix cannot silently disable the fence', () => {
|
||||
// startsWith('') is true for every slug, so a stray '' (an unset variable
|
||||
// in a provisioning template) would render as "bound" while fencing
|
||||
// nothing. Registration rejects it; the matcher ignores it anyway.
|
||||
test("[''] denies every slug rather than allowing every slug", async () => {
|
||||
const ctx = makeCtx({ auth: boundAuth(['']) });
|
||||
const p = op('put_page').handler(ctx, { slug: 'anywhere/at-all', content: 'stub' });
|
||||
await expect(p).rejects.toBeInstanceOf(OperationError);
|
||||
});
|
||||
|
||||
test("a real prefix alongside '' still fences to the real one", async () => {
|
||||
const ctx = makeCtx({ auth: boundAuth(['chan-eng/', '']) });
|
||||
const ok = await op('put_page').handler(ctx, { slug: 'chan-eng/x', content: 'stub' });
|
||||
expect(ok).toMatchObject({ dry_run: true });
|
||||
await expect(op('put_page').handler(ctx, { slug: 'other/x', content: 'stub' }))
|
||||
.rejects.toBeInstanceOf(OperationError);
|
||||
});
|
||||
|
||||
test('slugUnderBoundPrefixes ignores empty prefixes', () => {
|
||||
expect(slugUnderBoundPrefixes([''], 'anything')).toBe(false);
|
||||
expect(slugUnderBoundPrefixes(['a/'], 'a/b')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes(['a/'], 'b/a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch allow-list: unfenceable write ops are denied outright', () => {
|
||||
const bound = boundAuth(['emp-alice/']);
|
||||
const unbound = boundAuth(undefined);
|
||||
|
||||
// These write by a key other than a slug (derived entity names, numeric
|
||||
// fact ids), so no per-op fence can confine them.
|
||||
for (const name of ['extract_entities', 'extract_facts', 'forget_fact', 'ontology_propose']) {
|
||||
test(`${name} is denied for a bound client`, () => {
|
||||
const o = operations.find(x => x.name === name);
|
||||
if (!o) throw new Error(`${name} missing`);
|
||||
expect(() => enforceBoundClientOpAllowList(bound, o)).toThrow(/not available to slug-bound clients/);
|
||||
expect(() => enforceBoundClientOpAllowList(unbound, o)).not.toThrow();
|
||||
});
|
||||
}
|
||||
|
||||
test('every fenced write op is allowed', () => {
|
||||
for (const name of CLIENT_FENCED_WRITE_OPS) {
|
||||
const o = operations.find(x => x.name === name);
|
||||
if (!o) throw new Error(`${name} missing from operations`);
|
||||
expect(() => enforceBoundClientOpAllowList(bound, o)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('read ops are never gated', () => {
|
||||
for (const o of operations.filter(x => x.scope === 'read')) {
|
||||
expect(() => enforceBoundClientOpAllowList(bound, o)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
// The regression this exists to prevent: a write op added later must be
|
||||
// denied by default, not silently unfenced.
|
||||
test('a hypothetical new write op is denied by default', () => {
|
||||
expect(() => enforceBoundClientOpAllowList(bound, { name: 'brand_new_write_op', scope: 'write' }))
|
||||
.toThrow(/not available to slug-bound clients/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('both prefix grammars are accepted (the column predates this fence)', () => {
|
||||
// v85 introduced bound_slug_prefixes for submit_agent, whose grammar is
|
||||
// matchesSlugAllowList's `<prefix>/*` glob. Rejecting it here would deny
|
||||
// every direct write to already-configured clients on upgrade.
|
||||
test('a glob-style binding still matches', () => {
|
||||
expect(slugUnderBoundPrefixes(['wiki/agents/alice/*'], 'wiki/agents/alice/notes')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes(['wiki/agents/alice/*'], 'wiki/agents/bob/notes')).toBe(false);
|
||||
});
|
||||
|
||||
test('a trailing-slash binding still matches', () => {
|
||||
expect(slugUnderBoundPrefixes(['emp-alice/'], 'emp-alice/notes')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes(['emp-alice/'], 'emp-alice-evil/notes')).toBe(false);
|
||||
});
|
||||
|
||||
// The `emp-<slug>` scheme makes sibling collisions the common case:
|
||||
// `alice` and `alice-2` are different people. A plain startsWith let a
|
||||
// boundary-less binding reach the neighbour's namespace.
|
||||
test('a boundary-less prefix does NOT reach a sibling namespace', () => {
|
||||
expect(slugUnderBoundPrefixes(['emp-alice'], 'emp-alice/notes')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes(['emp-alice'], 'emp-alice')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes(['emp-alice'], 'emp-alice-2/onboarding')).toBe(false);
|
||||
expect(slugUnderBoundPrefixes(['emp-alice'], 'emp-alicexyz/secret')).toBe(false);
|
||||
});
|
||||
|
||||
test('trailing-slash and glob forms are equally boundary-safe', () => {
|
||||
for (const p of ['emp-alice/', 'emp-alice/*']) {
|
||||
expect(slugUnderBoundPrefixes([p], 'emp-alice/notes')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes([p], 'emp-alice-2/notes')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the canonical (lowercased) slug is what is matched', () => {
|
||||
// validateSlug lowercases before storage, so the fence must compare the
|
||||
// form that actually gets written — not the caller's raw string.
|
||||
expect(slugUnderBoundPrefixes(['emp-alice/'], 'EMP-ALICE/Notes')).toBe(true);
|
||||
expect(slugUnderBoundPrefixes(['emp-alice/'], 'EMP-BOB/Notes')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('degraded fence projection fails closed', () => {
|
||||
test('writes are refused when bound_slug_prefixes could not be read', async () => {
|
||||
const ctx = makeCtx({
|
||||
auth: { ...boundAuth(undefined), fenceProjectionDegraded: true },
|
||||
});
|
||||
const p = op('put_page').handler(ctx, { slug: 'anything/at-all', content: 'stub' });
|
||||
await expect(p).rejects.toBeInstanceOf(OperationError);
|
||||
await expect(p).rejects.toThrow(/cannot be evaluated/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composition with the subagent fence', () => {
|
||||
test('both fences apply: subagent namespace passes but client binding rejects', async () => {
|
||||
const ctx = makeCtx({
|
||||
viaSubagent: true,
|
||||
subagentId: 42,
|
||||
auth: boundAuth(['chan-eng/']),
|
||||
});
|
||||
const p = op('put_page').handler(ctx, { slug: 'wiki/agents/42/notes', content: 'stub' });
|
||||
await expect(p).rejects.toBeInstanceOf(OperationError);
|
||||
await expect(p).rejects.toThrow(/bound_slug_prefixes/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* E2E for the qm-harness integration recipe (docs/integrations/qm-harness.md):
|
||||
* roster-driven provisioning + over-the-wire write fencing.
|
||||
*
|
||||
* PGLite-based and ungated (no DATABASE_URL needed) — PGLite is
|
||||
* single-process, so every provisioning step runs BEFORE `serve --http`
|
||||
* starts; after that all access goes over HTTP MCP.
|
||||
*
|
||||
* Pins, end to end:
|
||||
* - provision-scopes.sh creates a path-less shared source + one bound
|
||||
* client per employee, is idempotent on re-run, and RESCOPES in place
|
||||
* (no secret rotation) when the roster changes;
|
||||
* - thin clients (`init --mcp-only`) can write inside their
|
||||
* bound_slug_prefixes and are rejected with the fence error outside
|
||||
* them (v0.42.70.0 enforceClientSlugFence, over the real transport);
|
||||
* - reads stay source-granular (a bob-example client CAN read
|
||||
* chan-eng/ — the documented shared-source tradeoff).
|
||||
*/
|
||||
|
||||
import { describe, test as testRaw, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
function test(name: string, fn: () => void | Promise<unknown>): void {
|
||||
testRaw(name, fn, 120000);
|
||||
}
|
||||
|
||||
const CLI = join(__dirname, '..', '..', 'src', 'cli.ts');
|
||||
const SCRIPT = join(__dirname, '..', '..', 'docs', 'integrations', 'qm-harness-snippets', 'provision-scopes.sh');
|
||||
|
||||
interface RunResult { exitCode: number; stdout: string; stderr: string; }
|
||||
|
||||
async function spawn(cmd: string[], env: Record<string, string | undefined>, cwd?: string): Promise<RunResult> {
|
||||
const fullEnv: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) fullEnv[k] = v;
|
||||
}
|
||||
delete fullEnv.GBRAIN_REMOTE_CLIENT_SECRET;
|
||||
delete fullEnv.DATABASE_URL;
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (v === undefined) delete fullEnv[k];
|
||||
else fullEnv[k] = v;
|
||||
}
|
||||
const proc = Bun.spawn({ cmd, env: fullEnv, cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
const gbrain = (args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) =>
|
||||
spawn(['bun', 'run', CLI, ...args], { GBRAIN_HOME: home, ...extraEnv });
|
||||
|
||||
describe('qm-harness provisioning + write fence (e2e, PGLite)', () => {
|
||||
let hostHome: string;
|
||||
let workDir: string;
|
||||
let aliceHome: string;
|
||||
let bobHome: string;
|
||||
let serverProc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
let serverPort: number;
|
||||
const creds: Record<string, { clientId: string; secret: string }> = {};
|
||||
let rerunCredsGrew = true; // set false when idempotency holds
|
||||
|
||||
const rosterPath = () => join(workDir, 'roster.tsv');
|
||||
const statePath = () => join(workDir, 'roster.tsv.state.tsv');
|
||||
const secretsPath = () => join(workDir, 'roster.tsv.new-credentials.tsv');
|
||||
|
||||
async function provision(): Promise<RunResult> {
|
||||
return spawn(
|
||||
['bash', SCRIPT, rosterPath(), '--gbrain', `bun run ${CLI}`, '--budget-usd-per-day', '5'],
|
||||
{ GBRAIN_HOME: hostHome },
|
||||
workDir,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
hostHome = mkdtempSync(join(tmpdir(), 'gbrain-qm-host-'));
|
||||
workDir = mkdtempSync(join(tmpdir(), 'gbrain-qm-work-'));
|
||||
aliceHome = mkdtempSync(join(tmpdir(), 'gbrain-qm-alice-'));
|
||||
bobHome = mkdtempSync(join(tmpdir(), 'gbrain-qm-bob-'));
|
||||
|
||||
// 1. Host brain on PGLite, embedding deferred (FTS is enough here).
|
||||
const init = await gbrain(['init', '--pglite', '--no-embedding'], hostHome);
|
||||
if (init.exitCode !== 0) throw new Error(`host init failed: ${init.stderr || init.stdout}`);
|
||||
|
||||
// 2. Roster v1: alice in eng, bob in product.
|
||||
writeFileSync(rosterPath(), [
|
||||
'channel eng',
|
||||
'channel product',
|
||||
'employee alice-example eng',
|
||||
'employee bob-example product',
|
||||
'',
|
||||
].join('\n'));
|
||||
const p1 = await provision();
|
||||
if (p1.exitCode !== 0) throw new Error(`provision v1 failed: ${p1.stderr || p1.stdout}`);
|
||||
|
||||
for (const line of readFileSync(secretsPath(), 'utf8').trim().split('\n')) {
|
||||
const [slug, clientId, secret] = line.split('\t');
|
||||
creds[slug] = { clientId, secret };
|
||||
}
|
||||
|
||||
// 3. Idempotency: re-run with the same roster mints no new secrets.
|
||||
const before = readFileSync(secretsPath(), 'utf8');
|
||||
const p2 = await provision();
|
||||
if (p2.exitCode !== 0) throw new Error(`provision re-run failed: ${p2.stderr || p2.stdout}`);
|
||||
rerunCredsGrew = readFileSync(secretsPath(), 'utf8') !== before;
|
||||
|
||||
// 4. Roster churn: alice joins product → rescope in place.
|
||||
writeFileSync(rosterPath(), [
|
||||
'channel eng',
|
||||
'channel product',
|
||||
'employee alice-example eng,product',
|
||||
'employee bob-example product',
|
||||
'',
|
||||
].join('\n'));
|
||||
const p3 = await provision();
|
||||
if (p3.exitCode !== 0) throw new Error(`provision rescope failed: ${p3.stderr || p3.stdout}`);
|
||||
|
||||
// 4b. An UNBOUND client, standing in for a webhook integration. Registered
|
||||
// here because PGLite is single-process: once serve --http holds the
|
||||
// lock, no host-side CLI command can run.
|
||||
const wh = await gbrain([
|
||||
'auth', 'register-client', 'webhook-integration',
|
||||
'--grant-types', 'client_credentials', '--scopes', 'read write',
|
||||
], hostHome);
|
||||
if (wh.exitCode !== 0) throw new Error(`webhook client registration failed: ${wh.stderr || wh.stdout}`);
|
||||
creds['webhook-integration'] = {
|
||||
clientId: wh.stdout.match(/Client ID:\s+(gbrain_cl_\S+)/)?.[1] ?? '',
|
||||
secret: wh.stdout.match(/Client Secret:\s+(gbrain_cs_\S+)/)?.[1] ?? '',
|
||||
};
|
||||
|
||||
// 5. Serve over HTTP MCP (holds the PGLite lock from here on).
|
||||
serverPort = 30000 + Math.floor(Math.random() * 30000);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
env.GBRAIN_HOME = hostHome;
|
||||
delete env.DATABASE_URL;
|
||||
serverProc = Bun.spawn({
|
||||
cmd: ['bun', 'run', CLI, 'serve', '--http', '--port', String(serverPort)],
|
||||
env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
const deadline = Date.now() + 30_000;
|
||||
let up = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${serverPort}/.well-known/oauth-authorization-server`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
if (res.ok) { up = true; break; }
|
||||
} catch { /* retry */ }
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
if (!up) throw new Error('serve --http did not come up');
|
||||
|
||||
// 6. Thin-client bootstrap for both scopes (the once-per-sandbox step).
|
||||
// --oauth-client-secret (NOT the env var) on purpose: an env-sourced
|
||||
// secret is deliberately not persisted to config.json, and qm has no
|
||||
// per-scope env to keep it in. Every later call below runs WITHOUT the
|
||||
// env var, so the suite proves the documented setup actually survives
|
||||
// the init session instead of masking it.
|
||||
for (const [slug, home] of [['alice-example', aliceHome], ['bob-example', bobHome]] as const) {
|
||||
const tc = await gbrain([
|
||||
'init', '--mcp-only',
|
||||
'--issuer-url', `http://127.0.0.1:${serverPort}`,
|
||||
'--mcp-url', `http://127.0.0.1:${serverPort}/mcp`,
|
||||
'--oauth-client-id', creds[slug].clientId,
|
||||
'--oauth-client-secret', creds[slug].secret,
|
||||
], home);
|
||||
if (tc.exitCode !== 0) throw new Error(`thin-client init (${slug}) failed: ${tc.stderr || tc.stdout}`);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverProc) {
|
||||
serverProc.kill();
|
||||
await serverProc.exited.catch(() => {});
|
||||
}
|
||||
for (const dir of [hostHome, workDir, aliceHome, bobHome]) {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// No GBRAIN_REMOTE_CLIENT_SECRET: auth must come from the persisted config.
|
||||
const asAlice = (args: string[]) => gbrain(args, aliceHome);
|
||||
const asBob = (args: string[]) => gbrain(args, bobHome);
|
||||
|
||||
async function mintToken(slug: string): Promise<string> {
|
||||
const { clientId, secret } = creds[slug];
|
||||
const res = await fetch(`http://127.0.0.1:${serverPort}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}`
|
||||
+ `&client_secret=${encodeURIComponent(secret)}&scope=${encodeURIComponent('read write')}`,
|
||||
});
|
||||
if (!res.ok) throw new Error(`token mint failed: ${res.status} ${await res.text()}`);
|
||||
return ((await res.json()) as { access_token: string }).access_token;
|
||||
}
|
||||
|
||||
async function mcpCall(token: string, toolName: string, args: Record<string, unknown>): Promise<string> {
|
||||
const res = await fetch(`http://127.0.0.1:${serverPort}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: toolName, arguments: args },
|
||||
}),
|
||||
});
|
||||
return res.text();
|
||||
}
|
||||
|
||||
test('provisioning minted one bound client per employee, exactly once', () => {
|
||||
// Only the roster-provisioned clients; 'webhook-integration' is registered
|
||||
// separately by the suite to prove the /ingest deny is scoped to bound clients.
|
||||
expect(Object.keys(creds).filter(k => k !== 'webhook-integration').sort())
|
||||
.toEqual(['alice-example', 'bob-example']);
|
||||
expect(creds['alice-example'].clientId).toStartWith('gbrain_cl_');
|
||||
expect(creds['alice-example'].secret).toStartWith('gbrain_cs_');
|
||||
expect(rerunCredsGrew).toBe(false);
|
||||
expect(existsSync(statePath())).toBe(true);
|
||||
});
|
||||
|
||||
test('alice writes inside her prefixes (personal + channel)', async () => {
|
||||
const own = await asAlice(['put', 'emp-alice-example/notes/hello', '--content', '# hello\nmine']);
|
||||
expect(own.exitCode).toBe(0);
|
||||
const chan = await asAlice(['put', 'chan-eng/notes/standup', '--content', '# standup\nshared']);
|
||||
expect(chan.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('roster churn took effect: alice can write chan-product/ after rescope', async () => {
|
||||
const joined = await asAlice(['put', 'chan-product/notes/joined', '--content', '# joined']);
|
||||
expect(joined.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("alice cannot write bob's namespace or an unbound prefix", async () => {
|
||||
const bobNs = await asAlice(['put', 'emp-bob-example/notes/nope', '--content', 'x']);
|
||||
expect(bobNs.exitCode).not.toBe(0);
|
||||
expect(bobNs.stdout + bobNs.stderr).toMatch(/bound_slug_prefixes/);
|
||||
|
||||
const stray = await asAlice(['put', 'org-notes/anything', '--content', 'x']);
|
||||
expect(stray.exitCode).not.toBe(0);
|
||||
expect(stray.stdout + stray.stderr).toMatch(/bound_slug_prefixes/);
|
||||
});
|
||||
|
||||
test('bob is fenced to HIS prefixes (not in eng)', async () => {
|
||||
const own = await asBob(['put', 'emp-bob-example/notes/hello', '--content', '# hi']);
|
||||
expect(own.exitCode).toBe(0);
|
||||
const eng = await asBob(['put', 'chan-eng/notes/nope', '--content', 'x']);
|
||||
expect(eng.exitCode).not.toBe(0);
|
||||
expect(eng.stdout + eng.stderr).toMatch(/bound_slug_prefixes/);
|
||||
});
|
||||
|
||||
test('reads stay source-granular: bob CAN read chan-eng pages (documented tradeoff)', async () => {
|
||||
const read = await asBob(['get', 'chan-eng/notes/standup']);
|
||||
expect(read.exitCode).toBe(0);
|
||||
expect(read.stdout).toContain('standup');
|
||||
});
|
||||
|
||||
test('the documented health check works on a read+write client (no admin scope)', async () => {
|
||||
const who = await asAlice(['whoami']);
|
||||
expect(who.exitCode).toBe(0);
|
||||
expect(who.stdout).toContain(creds['alice-example'].clientId);
|
||||
});
|
||||
|
||||
test('POST /ingest is closed to bound clients — it bypasses the op layer entirely', async () => {
|
||||
const post = async (token: string, slug: string | null) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'text/markdown',
|
||||
};
|
||||
if (slug) headers['X-Gbrain-Slug'] = slug;
|
||||
const res = await fetch(`http://127.0.0.1:${serverPort}/ingest`, {
|
||||
method: 'POST', headers,
|
||||
body: '---\ntype: note\ntitle: x\n---\n# injected',
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
};
|
||||
const bound = await mintToken('alice-example');
|
||||
|
||||
// The bypass this closes: /ingest queues a job for a handler that skips
|
||||
// the put_page op layer AND refuses to honor a source id for untrusted
|
||||
// payloads, so the write lands in the `default` source. Fencing only the
|
||||
// slug would still have written the right slug into the wrong source.
|
||||
const outside = await post(bound, 'wiki/ceo-comp');
|
||||
expect(outside.status).toBe(403);
|
||||
expect(outside.body).toContain('not available to clients restricted to slug prefixes');
|
||||
|
||||
// Even an IN-prefix slug is refused — the source, not just the slug, is
|
||||
// outside the client's grant.
|
||||
expect((await post(bound, 'emp-alice-example/inbox/note')).status).toBe(403);
|
||||
expect((await post(bound, null)).status).toBe(403);
|
||||
});
|
||||
|
||||
test('/ingest still works for an unbound webhook client (deny is scoped to bound clients)', async () => {
|
||||
expect(creds['webhook-integration'].clientId).toStartWith('gbrain_cl_');
|
||||
const token = await mintToken('webhook-integration');
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${serverPort}/ingest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'text/markdown',
|
||||
'X-Gbrain-Slug': 'inbox/webhook-note',
|
||||
},
|
||||
body: '---\ntype: note\ntitle: x\n---\n# from a webhook',
|
||||
});
|
||||
expect([200, 202]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('write ops that cannot be slug-fenced are denied to a bound client', async () => {
|
||||
// extract_entities mutates people/* and companies/* timelines; extract_facts
|
||||
// appends to any entity's fact fence; forget_fact targets a fact by numeric
|
||||
// id across sources; ontology_propose writes claims keyed to any entity.
|
||||
// None takes a fenceable slug, so all are denied at dispatch rather than
|
||||
// left silently unfenced.
|
||||
const token = await mintToken('alice-example');
|
||||
const cases: Array<[string, Record<string, unknown>]> = [
|
||||
['extract_entities', { text: 'Bob Victim did a bad thing.', source_slug: 'emp-alice-example/notes/hello' }],
|
||||
['extract_facts', { turn_text: 'Bob Victim admitted it.', entity_hints: ['people/bob-victim'] }],
|
||||
['forget_fact', { id: 1, reason: 'retracted' }],
|
||||
['ontology_propose', { entity: 'emp-bob-example/profile', dimension: 'role', value: 'terminated' }],
|
||||
];
|
||||
for (const [tool, args] of cases) {
|
||||
const body = await mcpCall(token, tool, args);
|
||||
expect(body).toMatch(/not available to slug-bound clients/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a fenced write op still works over the same transport (allow-list is not a blanket deny)', async () => {
|
||||
const token = await mintToken('alice-example');
|
||||
const ok = await mcpCall(token, 'put_page', {
|
||||
slug: 'emp-alice-example/notes/via-mcp', content: '# via mcp',
|
||||
});
|
||||
expect(ok).not.toMatch(/not available to slug-bound clients/);
|
||||
expect(ok).not.toMatch(/permission_denied/);
|
||||
|
||||
const denied = await mcpCall(token, 'put_page', {
|
||||
slug: 'emp-bob-example/notes/nope', content: '# nope',
|
||||
});
|
||||
expect(denied).toMatch(/bound_slug_prefixes/);
|
||||
});
|
||||
});
|
||||
+71
-1
@@ -231,7 +231,22 @@ describe('rescopeClient', () => {
|
||||
await expect(provider.rescopeClient(clientId, { sourceId: '../etc' })).rejects.toThrow('Invalid source_id');
|
||||
await expect(provider.rescopeClient(clientId, { federatedRead: ['ok', 'Not Valid!'] })).rejects.toThrow('Invalid source_id');
|
||||
await expect(provider.rescopeClient(clientId, { federatedRead: [] })).rejects.toThrow('cannot be empty');
|
||||
await expect(provider.rescopeClient(clientId, {})).rejects.toThrow('requires --source and/or --federated-read');
|
||||
await expect(provider.rescopeClient(clientId, {})).rejects.toThrow('requires --source, --federated-read, and/or --bound-slug-prefixes');
|
||||
// v0.42.70.0: an explicit empty prefix list is ambiguous (deny-all) — rejected.
|
||||
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: [] })).rejects.toThrow('cannot be an empty list');
|
||||
// An empty/whitespace ENTRY matches every slug under startsWith — it would
|
||||
// look like a binding while fencing nothing. Rejected at every write surface.
|
||||
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: [''] })).rejects.toThrow('non-empty');
|
||||
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: ['ok/', ' '] })).rejects.toThrow('non-empty');
|
||||
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: [' ok/'] })).rejects.toThrow('whitespace');
|
||||
// A boundary-less entry reads as a character prefix, so it would silently
|
||||
// cover sibling namespaces (emp-alice -> emp-alice-2/...).
|
||||
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: ['emp-alice'] })).rejects.toThrow('must end with');
|
||||
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: ['emp-alice/', 'chan-eng'] })).rejects.toThrow('must end with');
|
||||
await expect(provider.registerClientManual(
|
||||
'empty-prefix-reject', ['client_credentials'], 'read write', [], 'default', undefined, undefined,
|
||||
{ boundSlugPrefixes: [''] },
|
||||
)).rejects.toThrow('non-empty');
|
||||
await expect(provider.rescopeClient('gbrain_cl_nonexistent', { sourceId: 'wiki' })).rejects.toThrow('No OAuth client found');
|
||||
// FK: write source must exist in sources(id).
|
||||
await expect(provider.rescopeClient(clientId, { sourceId: 'no-such-source' })).rejects.toThrow('does not exist');
|
||||
@@ -240,6 +255,40 @@ describe('rescopeClient', () => {
|
||||
const [row] = await sql`SELECT source_id FROM oauth_clients WHERE client_id = ${clientId}`;
|
||||
expect(row.source_id).toBe('default');
|
||||
});
|
||||
|
||||
// v0.42.70.0: bound_slug_prefixes rescope — roster churn (channel
|
||||
// joins/leaves) updates the write fence in place; 'none' (null) clears it.
|
||||
test('bound_slug_prefixes: replace, leave-untouched, and clear; live tokens pick it up', async () => {
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
'rescope-fence', ['client_credentials'], 'read write', [], 'default', undefined, undefined, {
|
||||
boundSlugPrefixes: ['emp-carol/'],
|
||||
},
|
||||
);
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret!, 'read write');
|
||||
|
||||
// Replace the binding (carol joins chan-eng).
|
||||
const replaced = await provider.rescopeClient(clientId, { boundSlugPrefixes: ['emp-carol/', 'chan-eng/'] });
|
||||
expect(replaced.boundSlugPrefixes).toEqual(['emp-carol/', 'chan-eng/']);
|
||||
expect(replaced.sourceId).toBe('default'); // untouched
|
||||
|
||||
// The already-issued token sees the new binding on next verification.
|
||||
const live = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
|
||||
expect(live.boundSlugPrefixes).toEqual(['emp-carol/', 'chan-eng/']);
|
||||
|
||||
// Rescoping another axis leaves the binding untouched — and doesn't even
|
||||
// name the column, so brains predating it can still rescope --source.
|
||||
// `undefined` here means "not read this call", distinct from null = unset.
|
||||
const other = await provider.rescopeClient(clientId, { federatedRead: ['alpha'] });
|
||||
expect(other.boundSlugPrefixes).toBeUndefined();
|
||||
const stillBound = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
|
||||
expect(stillBound.boundSlugPrefixes).toEqual(['emp-carol/', 'chan-eng/']);
|
||||
|
||||
// null clears it — client returns to unbound full-source write authority.
|
||||
const cleared = await provider.rescopeClient(clientId, { boundSlugPrefixes: null });
|
||||
expect(cleared.boundSlugPrefixes).toBeNull();
|
||||
const unfenced = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
|
||||
expect(unfenced.boundSlugPrefixes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,6 +360,27 @@ describe('verifyAccessToken', () => {
|
||||
expect(authInfo.token).toBe(tokens.access_token);
|
||||
});
|
||||
|
||||
// v0.42.70.0: bound_slug_prefixes threads through token verification on
|
||||
// the same JOIN as source_id/federated_read, so enforceClientSlugFence
|
||||
// can fence direct writes without a per-op DB lookup.
|
||||
test('bound_slug_prefixes threads into AuthInfo; absent binding stays undefined', async () => {
|
||||
const bound = await provider.registerClientManual(
|
||||
'fence-thread-test', ['client_credentials'], 'read write', [], 'default', undefined, undefined, {
|
||||
boundSlugPrefixes: ['chan-eng/', 'wiki/agents/fence-thread-test/'],
|
||||
},
|
||||
);
|
||||
const boundTokens = await provider.exchangeClientCredentials(bound.clientId, bound.clientSecret!, 'read write');
|
||||
const boundInfo = await provider.verifyAccessToken(boundTokens.access_token) as unknown as CoreAuthInfo;
|
||||
expect(boundInfo.boundSlugPrefixes).toEqual(['chan-eng/', 'wiki/agents/fence-thread-test/']);
|
||||
|
||||
const unbound = await provider.registerClientManual(
|
||||
'fence-unbound-test', ['client_credentials'], 'read write',
|
||||
);
|
||||
const unboundTokens = await provider.exchangeClientCredentials(unbound.clientId, unbound.clientSecret!, 'read write');
|
||||
const unboundInfo = await provider.verifyAccessToken(unboundTokens.access_token) as unknown as CoreAuthInfo;
|
||||
expect(unboundInfo.boundSlugPrefixes).toBeUndefined();
|
||||
});
|
||||
|
||||
test('expired token is rejected', async () => {
|
||||
// Insert a token that's already expired
|
||||
const expiredToken = generateToken('gbrain_at_');
|
||||
|
||||
@@ -197,6 +197,37 @@ describe('submit_agent op (v0.38 Slice 3 — remote-callable agent dispatch with
|
||||
const result = await callSubmitAgent(ctx, { prompt: 'go' });
|
||||
expect(result.dry_run).toBe(true);
|
||||
});
|
||||
|
||||
// An EXPLICIT [] used to pass both subset loops vacuously and reach the
|
||||
// worker, which reads empty allowed_tools as "the whole registry" — so a
|
||||
// client bound to ['search'] got put_page. `??` doesn't substitute for an
|
||||
// empty array, only for null/undefined.
|
||||
it('collapses an explicit empty allowed_tools to the binding, not the full registry', async () => {
|
||||
await seedClient('cursor', {
|
||||
bound_tools: ['search'],
|
||||
bound_source_id: 'default',
|
||||
bound_slug_prefixes: ['wiki/'],
|
||||
});
|
||||
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
|
||||
const result = await callSubmitAgent(ctx, { prompt: 'go', allowed_tools: [] });
|
||||
expect(result.dry_run).toBe(true);
|
||||
expect(result.resolved_tools).toEqual(['search']);
|
||||
});
|
||||
|
||||
// Empty prefixes reached the subagent as "use the legacy
|
||||
// wiki/agents/<job-id>/ namespace" — outside every bound prefix.
|
||||
it('collapses an explicit empty allowed_slug_prefixes to the binding', async () => {
|
||||
await seedClient('cursor', {
|
||||
bound_tools: ['put_page'],
|
||||
bound_source_id: 'default',
|
||||
bound_slug_prefixes: ['emp-alice/'],
|
||||
});
|
||||
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
|
||||
const result = await callSubmitAgent(ctx, { prompt: 'go', allowed_slug_prefixes: [] });
|
||||
// Normalized into the glob the delegated matcher understands, so the
|
||||
// subagent can write descendants rather than one exact slug.
|
||||
expect(result.resolved_slug_prefixes).toEqual(['emp-alice/*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowed_slug_prefixes enforcement', () => {
|
||||
|
||||
Reference in New Issue
Block a user