Warden plan

17 - workspace/org content isolation across screens + MCP tools (#158)

← eval suite index


tier: needs-server requires: [needs-postgres]


17 - workspace/org content isolation across screens + MCP tools (#158)

What it proves

#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:

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.

Prerequisites

Step 1: web-screen scoping holds (static, repo tree)

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

Step 2: MCP cross-organization closure holds, against a live DB

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

Step 3: the still-open item — Socrates domain pickers subscribe to the active workspace (static)

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

Step 4: the still-open item, observed in a real browser

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

Shape (note for the next author)

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: