Warden plan
tier: needs-server requires: [needs-postgres]
#158 filed a wide audit of workspace-content leaks — several web screens kept showing content from every workspace in the org (a few MCP tools, every org in the deployment) even after the sidebar workspace switch. Filed BEFORE the member-wiki privacy (#291/#296) and access-scoping (#302) fixes landed. This plan re-audits every claimed leak against current canary and gates on what the audit found:
workspaceScoped() cache-key helper plus useWorkspaceWikis(workspaceId)/explicit workspaceId params, the MCP tools via resolveReadWorkspaceId wired into every gate-less resolver (commit c9e8cb55, "enforce workspace/organization isolation across MCP tools and Socrates", with a dedicated workspace-isolation.dbtest.test.ts that seeds two organizations and proves none of the seven can resolve the other org's rows by exact identifier). Steps 1-2 pin that this holds.DomainScope.tsx) and the Signal Graph domain selector (SignalGraph.tsx) both call the bare listDomains() helper directly instead of the workspace-aware useDomains hook, and neither subscribes to useWorkspaceSelection(). The request itself is not cross-tenant — listDomains() goes through the same activeWorkspaceId()-driven x-workspace-id header interceptor as every other scoped call — but because neither component's fetch effect depends on the active workspace, switching workspaces WHILE the picker/graph is mounted and open does not refetch: the list on screen keeps showing the workspace that was active when the effect last ran until the surface is closed and reopened. Step 3 gates the code-level fix; step 4 gates the observable behavior in a real browser.Cross-organization leaks (the seven MCP tools) are the closed, highest- severity half — verified first. The still-open item is cross-workspace staleness inside one org, the lower-severity half the sponsor's own scoping comment on #158 called the realistic worst case in a single-org deployment.
greenlight-pg :5433 with vector, greenlight-redis :6380) for step 2's dbtest.$APP_URL (default http://localhost:8080) proxying to the Hono server on $SERVER_URL (default http://localhost:3000).pnpm, grep, psql, jq, curl on PATH; npx agent-browser (0.26.x) for step 4. The env file is provisioned automatically by .warden/run.sh from the tracked .warden/env/ci-env.template.sh plus the machine-local secrets file ~/.config/robin/warden-secrets.sh (mode 600, outside every git repository; override with WARDEN_SECRETS_FILE); first-time setup: mkdir -p ~/.config/robin && cp .warden/env/secrets.local.example.sh ~/.config/robin/warden-secrets.sh && chmod 600 ~/.config/robin/warden-secrets.sh.robin_ci schema mid-suite, so any hand-seeded second workspace would be dead by the time this plan runs in suite order.set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# WI-1: the four unscoped-family hooks (#158's named root cause) fold the
# active workspace into their query key via workspaceScoped().
for f in useWikis useSignals usePeople useEntries; do
H="app/src/hooks/$f.ts"
if grep -q "workspaceScoped(" "$H"; then
warden_pass "$f wraps its query with workspaceScoped() (#158)"
else
warden_fail "$f no longer calls workspaceScoped() — its cache key can share rows across workspaces again (#158)"
fi
done
# WI-2: usePendingPersons carries the workspace in both the query param and
# the cache key (#158's "review queue showed root's people no matter which
# workspace you had selected" finding).
PP=app/src/hooks/usePendingPersons.ts
if grep -q "currentWorkspaceId ?? null" "$PP" && grep -q "workspaceId.*URLSearchParams\|set('workspaceId'" "$PP"; then
warden_pass "usePendingPersons keys and requests by the active workspace"
else
warden_fail "usePendingPersons regressed — no workspace-keyed query / param (#158)"
fi
# WI-3: the named screens use the workspace-scoped wiki hook, not the bare one.
declare -A SCREEN_FILE=(
["wiki-management/wikis page"]="app/src/app/(shell)/wiki-management/wikis/page.tsx"
["DomainWorkbench attach-picker"]="app/src/components/screens/library/DomainWorkbench.tsx"
["admin GuardiansTable wiki picker"]="app/src/components/screens/admin/GuardiansTable.tsx"
)
for name in "${!SCREEN_FILE[@]}"; do
f="${SCREEN_FILE[$name]}"
if grep -q "useWorkspaceWikis(" "$f"; then
warden_pass "$name uses useWorkspaceWikis (#158)"
else
warden_fail "$name no longer calls useWorkspaceWikis — reverted to the org-wide useWikis (#158): $f"
fi
done
# WI-4: PeopleGrid keys its /people query by the active workspace.
PG=app/src/components/screens/people/PeopleGrid.tsx
if grep -q "workspaceScoped(" "$PG"; then
warden_pass "PeopleGrid's /people query is workspace-keyed (#158)"
else
warden_fail "PeopleGrid no longer keys /people by workspace (#158)"
fi
# WI-5: SearchScreen wraps both search endpoints with workspaceScoped.
SS=app/src/components/screens/search/SearchScreen.tsx
if [ "$(grep -c "workspaceScoped(" "$SS")" -ge 2 ] 2>/dev/null; then
warden_pass "SearchScreen wraps both signals and wikis search with workspaceScoped (#158)"
else
warden_fail "SearchScreen's search/search_wikis calls are not both workspace-keyed (#158): $SS"
fi
# WI-6: HomeOverview's review queue passes the active workspace to both its
# sub-queries (the "correctly-scoped stat strip, unscoped review queue" bug).
HO=app/src/components/screens/home/HomeOverview.tsx
if grep -q "workspaceId: reviewWorkspaceId ?? undefined" "$HO" && grep -q "usePendingPersons()" "$HO"; then
warden_pass "HomeOverview's review queue (suggestions + pending persons) is workspace-scoped (#158)"
else
warden_fail "HomeOverview's review queue lost its workspace scoping (#158): $HO"
fi
# WI-7 (server-side gap #158 called out): GET /people and GET /graph accept a
# workspaceId query param and default to readWorkspaceId, not an org-wide scan.
for pair in "server/src/modules/people/routes.ts" "server/src/modules/graph/routes.ts"; do
if grep -q "readWorkspaceId" "$pair"; then
warden_pass "$pair defaults its workspace scope to readWorkspaceId (#158 server-side gap closed)"
else
warden_fail "$pair lost its readWorkspaceId default — the API-contract gap #158 flagged is back: $pair"
fi
done
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"
# The dedicated #158 dbtest: seeds two organizations and proves list_wikis,
# get_wiki, get_signal, find_person, brief_person, list_skills, get_timeline,
# search, search_wikis, handleAttachSignals, and resolveReadWorkspaceId's
# explicit-ref path never resolve/return another org's (or, for the explicit-
# ref case, another workspace in the caller's OWN org they don't belong to)
# rows — this is the cross-org half of the audit, the severity priority.
if pnpm --filter @robin/server exec vitest run \
src/mcp/__tests__/workspace-isolation.dbtest.test.ts \
>/tmp/warden-ws-isolation-mcp.log 2>&1; then
warden_pass "MCP cross-org read-tool isolation dbtest passes (#158)"
else
tail -25 /tmp/warden-ws-isolation-mcp.log
warden_fail "MCP cross-org isolation dbtest FAILED — a #158 closure regressed; see /tmp/warden-ws-isolation-mcp.log"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# WI-8/WI-9: DomainScope and SignalGraph must no longer fetch domains via the
# bare, workspace-blind listDomains() call with no dependency on the active
# workspace. The fix can take either shape: switch to the useDomains(workspaceId)
# hook (matching every other fixed screen), or keep a raw fetch but add
# useWorkspaceSelection() and include currentWorkspaceId in the effect's
# dependency array so a workspace switch while the surface is open refetches.
for pair in "app/src/components/socrates/DomainScope.tsx:DomainScope" \
"app/src/components/socrates/graph/SignalGraph.tsx:SignalGraph"; do
f="${pair%%:*}"; name="${pair##*:}"
if grep -q "useWorkspaceSelection" "$f"; then
warden_pass "$name reads useWorkspaceSelection() — no longer workspace-blind (#158)"
else
warden_fail "$name still never reads useWorkspaceSelection() — a workspace switch while it's open won't refetch (#158)"
fi
if grep -q "currentWorkspaceId" "$f"; then
warden_pass "$name's fetch is wired to currentWorkspaceId"
else
warden_fail "$name has no currentWorkspaceId dependency for its domain fetch (#158)"
fi
done
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "Socrates domain picker follows a live workspace switch" "npx agent-browser is unavailable on this box"
else
# Self-provision a second workspace + a uniquely-named domain in it under
# the warden identity's own org, idempotently.
WS2_ID=$(warden_psql_one "SELECT id FROM workspaces WHERE name = 'Warden WS2 (#158)' LIMIT 1")
if [ -z "$WS2_ID" ]; then
ORG_ID=$(warden_psql_one "SELECT organization_id FROM member ORDER BY created_at LIMIT 1")
WS2_ID="warden-ws2-158"
warden_psql_exec "INSERT INTO workspaces (id, organization_id, name, slug, created_at, updated_at)
VALUES ('$WS2_ID', '$ORG_ID', 'Warden WS2 (#158)', 'warden-ws2-158', now(), now())
ON CONFLICT (id) DO NOTHING"
fi
# Membership runs UNCONDITIONALLY and with an explicit id: the original
# insert omitted `id` (NOT NULL, no default → it never landed) and only ran
# on the freshly-created branch, so an existing WS2 stayed member-less
# forever and the switcher never offered it (observed 20260820). The signed
# in identity is andrew — target him by email, not by member row age.
WARDEN_USER_ID=$(warden_psql_one "SELECT id FROM users WHERE email='andrew@robin.ai'")
warden_psql_exec "INSERT INTO workspace_members (id, workspace_id, user_id, role, created_at)
VALUES ('warden-ws2-158-andrew', '$WS2_ID', '$WARDEN_USER_ID', 'workspace_admin', now())
ON CONFLICT (workspace_id, user_id) DO NOTHING"
warden_psql_exec "INSERT INTO knowledge_domains (id, workspace_id, name, slug, created_at, updated_at)
SELECT 'warden-domain-158', '$WS2_ID', 'Warden Domain B (#158)', 'warden-domain-b-158', now(), now()
WHERE NOT EXISTS (SELECT 1 FROM knowledge_domains WHERE slug = 'warden-domain-b-158' AND workspace_id = '$WS2_ID')"
# NOTE (run 20260819T025944 postmortem): the original script clicked the
# sidebar workspace switcher WHILE the Knowledge-domains dialog was open.
# That scenario is unreachable in the product — the dialog is modal (the
# switcher click no-ops behind the overlay), and a real switch navigates
# home, unmounting the surface. The live in-dialog refetch behavior is
# pinned at code level by step 3 plus the component tests
# (DomainScope.test.tsx / SignalGraph.test.tsx "workspace switches while
# mounted"). Here we gate the reachable behavior: switch to workspace B
# FIRST (via the real sidebar switcher — its trigger is the
# aria-label="Switch workspace" pill; there is no data-testid), then open
# each surface and confirm it shows B's domains, not A's.
npx agent-browser open "$APP_BASE/socrates" >/dev/null
# The persistent browser session may be signed out (observed 20260820: both
# assertions red because /socrates bounced to /login and every later
# selector matched nothing). Sign in as the seeded owner when redirected.
if npx agent-browser eval "location.pathname" 2>/dev/null | grep -q login; then
npx agent-browser fill "input[type='email']" "andrew@robin.ai" >/dev/null 2>&1
npx agent-browser fill "input[type='password']" "robin2026" >/dev/null 2>&1
npx agent-browser click "button[type='submit']" >/dev/null 2>&1
npx agent-browser wait 3000 >/dev/null 2>&1
npx agent-browser open "$APP_BASE/socrates" >/dev/null
fi
ORIG_WS=$(npx agent-browser eval "localStorage.getItem('robin.currentWorkspaceId')" 2>/dev/null)
# agent-browser 0.26.x: the `text=`/:has-text() engines no longer match
# these menu items/buttons (observed 20260820 — both clicks silently
# no-oped and the assertions red'd with the product actually green).
# Click via snapshot refs instead: snapshot, grep the ref, click @ref.
npx agent-browser click "[aria-label='Switch workspace']" >/dev/null 2>&1
WS2_REF=$(npx agent-browser snapshot 2>/dev/null | grep 'menuitem "Warden WS2' | grep -o 'ref=[a-z0-9]*' | head -1 | cut -d= -f2)
[ -n "$WS2_REF" ] && npx agent-browser click "@$WS2_REF" >/dev/null 2>&1
npx agent-browser wait 1500 >/dev/null 2>&1
# Selecting a workspace navigates home; return to Socrates with B active.
npx agent-browser open "$APP_BASE/socrates" >/dev/null
DOM_REF=$(npx agent-browser snapshot 2>/dev/null | grep 'button "All domains"' | grep -o 'ref=[a-z0-9]*' | head -1 | cut -d= -f2)
[ -n "$DOM_REF" ] && npx agent-browser click "@$DOM_REF" >/dev/null 2>&1
npx agent-browser wait "[role='dialog']" >/dev/null 2>&1
DOMAIN_VISIBLE=$(npx agent-browser get text "[role='dialog']" 2>/dev/null | grep -c "Warden Domain B (#158)")
[ "${DOMAIN_VISIBLE:-0}" -ge 1 ] \
&& warden_pass "the domain-scope picker shows workspace B's domain while B is active (#158)" \
|| warden_fail "the domain-scope picker does not show workspace B's domain with B active — still workspace-blind (#158)"
npx agent-browser press "Escape" >/dev/null 2>&1
# Negative case, same defect class: the Signal Graph domain <select> must
# not offer workspace A's domains while workspace B is active.
npx agent-browser open "$APP_BASE/socrates/graph" >/dev/null
npx agent-browser wait "select" >/dev/null 2>&1
OPTIONS=$(npx agent-browser get text "select" 2>/dev/null)
case "$OPTIONS" in
*"Warden Domain B (#158)"*) warden_pass "Signal Graph's domain select offers workspace B's domain while B is active" ;;
*) warden_fail "Signal Graph's domain select did not pick up workspace B's domain (#158)" ;;
esac
# Restore the workspace that was active before this step so later plans
# (and a human at the browser) don't inherit the warden workspace.
if [ -n "$ORIG_WS" ] && [ "$ORIG_WS" != "null" ]; then
npx agent-browser eval "localStorage.setItem('robin.currentWorkspaceId', '$ORIG_WS')" >/dev/null 2>&1
else
npx agent-browser eval "localStorage.removeItem('robin.currentWorkspaceId')" >/dev/null 2>&1
fi
fi
Steps 1-2 are the closed-and-regressed-against half of the audit — every item #158 named except the two Socrates domain pickers is already fixed on canary (commit c9e8cb55 for MCP, several small app fixes for the web screens; see git log --oneline | grep -i scope for the full trail). Steps 3-4 gate the one item still open. Step 4 needs a real browser and the live stack; step 3 alone is enough to catch a naive fix (e.g. adding the import without wiring the dependency).
Deliberately not gated here:
isRLSEnabled: false on every table today. Its own filing note says it becomes its own issue if it grows beyond one PR; this plan does not treat its absence as a #158 regression.get_wiki_types, list_domains, get_domain_signals, get_domain_graph, create_domain (MCP) — the issue called these "scoped, but availability- limited" (correctly filtered, but no non-root-workspace path), a completeness gap rather than a leak. Not asserted here.agent-ability.ts, permission-port.ts, registry.ts, alias-registry.ts, regen-debounce.ts — the issue flagged these as "not yet analyzed" at filing time. Out of scope for this plan; a fresh audit pass would need to happen before writing predicates for them.search/search_wikis "everything I can see" question — the issue raised this as a product ambiguity, not a bug. The dbtest in step 2 pins the CURRENT behavior (single active workspace, no cross-org union); if the product decision changes, that test — not this plan — is where the behavior gets re-specified.