* fix(security): fence the dedup-resolved slug under the caller's own confinement put_page's resolved-slug re-check tested `ctx.auth.boundSlugPrefixes` only. The delegated submit_agent -> subagent context carries `viaSubagent` + `allowedSlugPrefixes` but no `auth`, so a slug-bound client holding `agent` scope could delegate a write and have importFromContent's dedup pre-check redirect it onto a page outside its grant — where the disk write-through then re-rendered the victim's file with the caller's provenance. The re-check now applies whichever confinement the caller is actually under (OAuth binding and/or subagent allow-list / legacy namespace) via `slugOutsideCallerFence`, which composes the existing match rules rather than re-deriving them. Dedup returns status 'skipped' before any DB write, so the throw still rolls nothing back. The denial does not name the resolved slug (slug-enumeration oracle) and reads "your write scope", since either confinement can trigger it. Reported privately by Aleksei Razsadin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: coverage for the OAuth in-fence redirect and the missing-subagentId guard * v0.42.73.2 fix(security): fence dedup-resolved writes to the caller's own write scope VERSION + package.json + CHANGELOG for 0.42.73.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: state that the write fence follows a delegated write --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
12 KiB
qm (multi-user agent harness) — gbrain as the company brain
Connect gbrain to 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 readsagents+ 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 ownemp-<slug>/and its channels'chan-<x>/prefixes — onput_page,delete_page,restore_page,add_tag,remove_tag,add_link/remove_link,add_timeline_entry,revert_versionandput_raw_data, plus thePOST /ingestwebhook 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_entitiesandextract_facts(which mutatepeople/*andcompanies/*),forget_fact(targets a fact by numeric id, across sources),ontology_propose, and thesources_adminpairsources_add/sources_remove— cannot be fenced by slug, so a bound client getspermission_deniedat 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 toCLIENT_FENCED_WRITE_OPS(src/core/operations.ts).thinkis allow-listed because remote callers cannot persist from it;submit_agentbecause 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## Factsfence on git-backed sources) ontopeople/*pages the caller never named — the same capabilityextract_factsis denied for, reached through an in-prefix write. It is skipped for bound clients.POST /ingestis refused outright: its handler bypasses the op layer and discards the source grant for untrusted payloads, so it would write into thedefaultsource.
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.73.2:
-
The fence follows a delegated write. When a client with
agentscope hands work to a subagent viasubmit_agent, that subagent runs under its own slug confinement rather than the parent's OAuth binding. Both confinements are enforced, including on the path where deduplication redirects a write onto an existing page: the redirected target is checked against whichever confinement the calling context actually carries, so delegation does not widen what a client can write. -
add_link/remove_linkfence thefromendpoint only. A bound client can create an edge pointing AT a page it cannot write; the edge'scontexttext surfaces in that page's backlinks and contributes to its search ranking. Fencingtowould break legitimate cross-referencing intoorg-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_pagecan 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 (theagentssource 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, andsources_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_aton 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 /ingestwrites land in thedefaultsource regardless of the calling client'ssource_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'sdefaultsource deliberately. -
Tradeoff to state out loud: read isolation is per-source, so within the shared
agentssource 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 (onesources add emp-<slug>+--source emp-<slug>per client) and keep channel prefixes inagentsvia 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)
# 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
converges the brain to a roster file
(roster.example.tsv):
bash provision-scopes.sh roster.tsv --read-sources org-wiki
- Creates the path-less
agentssource (agent-written memory needs no git clone; if the host hassync.repo_pathconfigured, 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-cliented 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):
- Tool: copy
qm-harness-snippets/tool.jsontosandbox/tools/gbrain/tool.jsonand drop the compiledgbrainbinary beside it (bun build --compile --outfile gbrain src/cli.ts, built for the sandbox image's OS/arch).auth.credentialPathsmarks~/.gbrain/config.jsonas the scope's resident credential file;auth.checkwiresgbrain whoamiinto qm's connector status (read-scope; see the note below on whyremote doctorcannot be used here). - Skill: copy
qm-harness-snippets/SKILL.mdtosandbox/skills/gbrain/SKILL.md(edit slug conventions to taste). - Ship it:
qm sandbox build && qm sandbox publish && qm up.
Per scope, one-time (agent- or operator-run, credentials from the handoff):
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):
# 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; seedocs/guides/search-modes.mdfor the cost matrix before changing it.- Budgets:
--budget-usd-per-dayis recorded on the client but only enforced on thesubmit_agentpath (src/core/minions/budget-meter.ts), which theseread writeclients cannot reach — so it does not cap spend from ordinarysearch/put_pagetraffic. Treat runaway-agent containment as an open item: watch the admin SPA (/admin) andgbrain 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
MemoryServicedecorator (mirror notebook captures into gbrain, fanrecallout and merge,volunteer_contextpush): needs a qm code change; today's integration is agent-initiated via the CLI + skill. - MCP-native attach: qm pins
strictMcpConfigwith 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.