Compare commits

...

2 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) c8e19bbdcd fix(registry,discovery,mcp): prevent cross-tenant peer metadata leak (#1953)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 11s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 19s
E2E Chat / detect-changes (pull_request) Successful in 15s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 55s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 48s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m15s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 6m22s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
E2E Chat / E2E Chat (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m44s
Harness Replays / Harness Replays (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 2m18s
CI / Platform (Go) (pull_request) Successful in 6m54s
CI / Canvas (Next.js) (pull_request) Successful in 7m20s
CI / all-required (pull_request) Successful in 33m31s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Waiting to run
Three code paths used `parent_id IS NULL` to identify 'siblings' of a
root-level workspace. Because every org root has `parent_id IS NULL`,
this predicate matched ALL org roots across ALL tenants, leaking peer
metadata and allowing cross-tenant A2A delegation.

Fixes:
- registry/access.go: Remove the root-level sibling fast-path in
  `CanCommunicate`. Two distinct workspaces with `parent_id = NULL`
  are in different orgs and must not communicate.
- discovery.go: Skip the sibling query entirely when the caller is an
  org root (parent_id IS NULL). Each org has exactly one root.
- mcp_tools.go: Same fix for the MCP `list_peers` tool path.

Tests updated to reflect the new security boundary:
- TestCanCommunicate_RootSiblings → TestCanCommunicate_Denied_RootCrossTenant
- Tests that mocked root-root communication now use parent-child or
  shared-parent relationships so they continue to test allowed paths.

All 695 workspace-server tests pass with -race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:02:21 +00:00
Molecule AI Dev Engineer A (Kimi) 2a04e9bec1 docs: fix stale molecule-monorepo references (#1837)
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m24s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m12s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 8m4s
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck; security-review failed
gate-check-v3 / gate-check (pull_request) Successful in 15s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request_review) Successful in 6s
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Successful in 2s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
qa-review / approved (pull_request_target) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 3s
security-review / approved (pull_request_target) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1s
CI / Platform (Go) (pull_request) Successful in 6s
Harness Replays / Harness Replays (pull_request) Successful in 1s
gate-check-v3 / gate-check (pull_request_target) Successful in 15s
sop-tier-check / tier-check (pull_request_target) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
E2E Chat / E2E Chat (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
CI / Canvas (Next.js) (pull_request) Successful in 6m18s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 7m23s
audit-force-merge / audit (pull_request_target) Successful in 4s
Replace remaining user-facing references to the old repo name
molecule-monorepo with molecule-core in clone instructions,
documentation links, path examples, and source links.

Affected files:
- README.md (clone commands in Quick Start)
- docs/quickstart.md (clone commands in one-command and manual paths)
- docs/architecture/molecule-technical-doc.md (repo links)
- docs/development/local-development.md (path example)
- docs/infra/workspace-terminal.md (factually incorrect rename claim)
- docs/integrations/opencode.md (task example)
- docs/internal-content-policy.md (repo name and path references)
- canvas/src/app/pricing/page.tsx (source code link)
- .env.example (repo name in comment)
- tools/check-template-parity.sh (path example in comment)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:14:01 +00:00
18 changed files with 65 additions and 83 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ MOLECULE_ENV=development # Environment label (development/
# MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false). Triggers A2A proxy to rewrite 127.0.0.1:<port> agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails or to force off.
# GitHub
# GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-monorepo). Read inside workspace containers.
# GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-core). Read inside workspace containers.
# GITHUB_TOKEN= # Personal access token / installation token used by agents that clone private repos. Register as a global secret via POST /admin/secrets for propagation to workspace env. Token is used in-URL during clone and then scrubbed from .git/config via `git remote set-url`.
# Webhooks
+2 -2
View File
@@ -49,8 +49,8 @@
## Quick Start
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
cd molecule-core
./scripts/dev-start.sh
```
+1 -1
View File
@@ -41,7 +41,7 @@ export default function PricingPage() {
<p className="mt-2 text-ink-mid">
We publish the{" "}
<a
href="https://git.moleculesai.app/molecule-ai/molecule-monorepo"
href="https://git.moleculesai.app/molecule-ai/molecule-core"
className="text-accent underline hover:text-accent"
>
full source on GitHub
+6 -6
View File
@@ -1,7 +1,7 @@
# Molecule AI — Comprehensive Technical Documentation
> Definitive technical reference for the Molecule AI Agent Team platform.
> Based on a full non-invasive scan of the [molecule-monorepo](https://git.moleculesai.app/molecule-ai/molecule-monorepo) repository.
> Based on a full non-invasive scan of the [molecule-core](https://git.moleculesai.app/molecule-ai/molecule-core) repository.
---
@@ -1131,11 +1131,11 @@ Molecule AI's workspace abstraction is **runtime-agnostic by design**. A workspa
## Links
- **GitHub**: https://git.moleculesai.app/molecule-ai/molecule-monorepo
- **Architecture Docs**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/architecture
- **API Protocol**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/api-protocol
- **Agent Runtime**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/agent-runtime
- **Product Docs**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/product
- **GitHub**: https://git.moleculesai.app/molecule-ai/molecule-core
- **Architecture Docs**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/architecture
- **API Protocol**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/api-protocol
- **Agent Runtime**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/agent-runtime
- **Product Docs**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/product
---
+1 -1
View File
@@ -82,7 +82,7 @@ DATABASE_URL=postgres://dev:dev@postgres:5432/molecule?sslmode=prefer
REDIS_URL=redis://redis:6379
PORT=8080
SECRETS_ENCRYPTION_KEY=dev-key-change-in-production
WORKSPACE_DIR=/path/to/molecule-monorepo # Optional global fallback; prefer per-workspace workspace_dir in org.yaml or API
WORKSPACE_DIR=/path/to/molecule-core # Optional global fallback; prefer per-workspace workspace_dir in org.yaml or API
```
### Canvas (Next.js)
+3 -5
View File
@@ -16,11 +16,9 @@ workspace container running on it) over an [EC2 Instance Connect
Endpoint](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-connect-setup-ec2-instance-connect-endpoint.html).
End users see a terminal; no direct public SSH ingress is required.
Tracking: originally `molecule-core#1528` (resolved 2026-04-22). The
`molecule-core` repo has since been renamed to `molecule-monorepo` and no
longer accepts new issues under the old name; future terminal work is
tracked in `molecule-monorepo` issues (workspace-server scope) and in
`molecule-controlplane` issues for the EIC / per-tenant SG path.
Tracking: originally `molecule-core#1528` (resolved 2026-04-22). Future
terminal work is tracked in `molecule-core` issues (workspace-server scope)
and in `molecule-controlplane` issues for the EIC / per-tenant SG path.
## Where things are
+1 -1
View File
@@ -64,7 +64,7 @@ When opencode connects to the Molecule MCP endpoint, the agent gains access to:
"tool": "delegate_task",
"arguments": {
"target": "research-lead",
"task": "Summarise the last 7 days of commits in Molecule-AI/molecule-monorepo"
"task": "Summarise the last 7 days of commits in Molecule-AI/molecule-core"
}
}
```
+7 -7
View File
@@ -1,6 +1,6 @@
# Internal content policy
The `Molecule-AI/molecule-monorepo` repo is **public**. Anything internal
The `Molecule-AI/molecule-core` repo is **public**. Anything internal
(positioning, competitive briefs, sales playbooks, PMM/press drip, draft
campaigns, raw research notes, ops runbooks, retrospectives) lives in
**`Molecule-AI/internal`**.
@@ -18,14 +18,14 @@ This page is the canonical decision tree.
| Draft campaign asset (still iterating, not yet customer-visible) | `Molecule-AI/internal/marketing/campaigns/` |
| Roadmap discussion, planning doc, retrospective | `Molecule-AI/internal/PLAN.md` or `Molecule-AI/internal/retrospectives/` |
| Runbook, ops procedure, incident postmortem | `Molecule-AI/internal/runbooks/` |
| **Public-ready** blog post (final draft, ready to ship to docs site) | `Molecule-AI/molecule-monorepo/docs/blog/` |
| **Public-ready** tutorial / quickstart | `Molecule-AI/molecule-monorepo/docs/tutorials/` |
| Public DevRel content (code samples, demos for users) | `Molecule-AI/molecule-monorepo/docs/devrel/` |
| API reference, architecture docs for external developers | `Molecule-AI/molecule-monorepo/docs/api/` |
| **Public-ready** blog post (final draft, ready to ship to docs site) | `Molecule-AI/molecule-core/docs/blog/` |
| **Public-ready** tutorial / quickstart | `Molecule-AI/molecule-core/docs/tutorials/` |
| Public DevRel content (code samples, demos for users) | `Molecule-AI/molecule-core/docs/devrel/` |
| API reference, architecture docs for external developers | `Molecule-AI/molecule-core/docs/api/` |
| Code, tests, infrastructure | wherever is appropriate inside this repo |
**Rule of thumb:** *"Would I be comfortable if a competitor / journalist / customer
read this verbatim today?"* — yes → `monorepo/docs/`. No / not yet → `internal/`.
read this verbatim today?"* — yes → `molecule-core/docs/`. No / not yet → `internal/`.
## Why
@@ -82,7 +82,7 @@ git push -u origin HEAD
gh pr create --base main --fill
```
Yes, this is more steps than `cd molecule-monorepo && git add research/foo.md`.
Yes, this is more steps than `cd molecule-core && git add research/foo.md`.
That cost is intentional: the friction is the point. Public space and
internal space are different products with different audiences and
different durability guarantees.
+4 -4
View File
@@ -17,8 +17,8 @@ This path is aligned to the current repository and current UI. It gets you from
## The one-command path
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
cd molecule-core
./scripts/dev-start.sh
```
@@ -42,8 +42,8 @@ If you'd rather run each component yourself — useful when you're iterating on
### Step 1: Clone the repository
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
cd molecule-core
```
### Step 2: Start the shared infrastructure
+1 -1
View File
@@ -13,7 +13,7 @@
#
# Invocation (from template-hermes repo's CI):
#
# bash /path/to/molecule-monorepo/tools/check-template-parity.sh \
# bash /path/to/molecule-core/tools/check-template-parity.sh \
# install.sh start.sh
#
# Or inline via curl:
@@ -1059,13 +1059,13 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
WillReturnResult(sqlmock.NewResult(0, 1))
// CanCommunicate: getWorkspaceRef(source) + getWorkspaceRef(target).
// Both are root-level workspaces (parent_id=NULL) → root-level siblings → allowed.
// Source is root-level, target is its child → parent→child communication allowed.
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
WithArgs(testDeliverySourceID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliverySourceID, nil))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
WithArgs(testDeliveryTargetID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliveryTargetID, nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliveryTargetID, testDeliverySourceID))
// resolveAgentURL: test callers always set the URL in Redis (mr.Set ws:{id}:url),
// so resolveAgentURL gets a cache hit and never falls back to DB.
@@ -246,15 +246,11 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
parentID.String, workspaceID)
peers = append(peers, siblings...)
} else {
siblings, _ := queryPeerMaps(`
SELECT w.id, w.name, COALESCE(w.role, ''), w.tier, w.status,
COALESCE(w.agent_card, 'null'::jsonb), COALESCE(w.url, ''),
w.parent_id, w.active_tasks
FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
workspaceID)
peers = append(peers, siblings...)
}
// Root workspaces (parent_id IS NULL) have no siblings: each org has
// exactly one root, so a distinct workspace with parent_id IS NULL
// belongs to a different tenant. Querying `parent_id IS NULL` would
// leak every org root across tenants (security #1953).
// Children — exclude self defensively. A child row whose parent_id
// equals the requesting workspaceID can never legitimately be the
@@ -223,10 +223,8 @@ func TestPeers_RootWorkspace_NoPeers(t *testing.T) {
peerCols := []string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}
// Siblings (other root-level workspaces) — none
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id IS NULL AND w.id != \\$1").
WithArgs("ws-root-alone").
WillReturnRows(sqlmock.NewRows(peerCols))
// Siblings — root workspaces have none (each org has exactly one root;
// querying `parent_id IS NULL` would leak all org roots cross-tenant).
// Children — none. #383 added explicit `w.id != $2` self-filter.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
@@ -959,15 +957,12 @@ func TestPeers_DevModeFailOpen_AllowsBearerlessRequest(t *testing.T) {
peersAuthFixtureHasLiveToken(mock, "ws-dev")
// Root workspace → children+parent queries still fire but the
// parent_id lookup comes first.
// Root workspace → no sibling query (security #1953), children+parent
// queries still fire, and the parent_id lookup comes first.
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
WithArgs("ws-dev").
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
peerCols := []string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id IS NULL AND w.id").
WithArgs("ws-dev").
WillReturnRows(sqlmock.NewRows(peerCols))
// #383 — children query gained explicit `w.id != $2` self-filter.
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-dev", "ws-dev").
@@ -576,13 +576,13 @@ func TestDiscover_TargetOffline(t *testing.T) {
setupTestRedis(t)
handler := NewDiscoveryHandler()
// Both root-level, access allowed
// Same-parent siblings, access allowed
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-caller").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", "ws-parent"))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-off").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-off", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-off", "ws-parent"))
// Name + runtime lookup (discovery now queries both)
mock.ExpectQuery("SELECT COALESCE").
@@ -622,13 +622,13 @@ func TestCheckAccess_SiblingsAllowed(t *testing.T) {
setupTestRedis(t)
handler := NewDiscoveryHandler()
// Both root-level siblings → allowed
// Siblings under a shared parent → allowed
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-a").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", "ws-parent"))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-b").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", "ws-parent"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -376,14 +376,13 @@ func TestExtended_DiscoverWithCallerID(t *testing.T) {
handler := NewDiscoveryHandler()
// CanCommunicate needs to look up both workspaces
// Caller: root-level (no parent)
// Caller and target are siblings under a shared parent
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-caller").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", nil))
// Target: also root-level (no parent) — root-level siblings are allowed
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", "ws-parent"))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-target").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-target", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-target", "ws-parent"))
// Discover handler looks up workspace name + runtime
mock.ExpectQuery("SELECT COALESCE").
@@ -458,14 +457,14 @@ func TestExtended_Peers(t *testing.T) {
setupTestRedis(t)
handler := NewDiscoveryHandler()
// Expect parent_id lookup for requesting workspace (root-level, no parent)
// Expect parent_id lookup for requesting workspace (has a parent)
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
WithArgs("ws-peer").
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow("ws-parent"))
// Expect root-level siblings query (parent IS NULL, excluding self)
// Expect siblings query (same parent, excluding self)
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("ws-peer").
WithArgs("ws-parent", "ws-peer").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}).
AddRow("ws-sibling", "Sibling Agent", "worker", 1, "online", []byte("null"), "http://localhost:9001", nil, 0))
@@ -512,13 +511,13 @@ func TestExtended_CheckAccess(t *testing.T) {
handler := NewDiscoveryHandler()
// CanCommunicate will look up both workspaces
// Both root-level — should be allowed
// Siblings under a shared parent — should be allowed
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-a").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", "ws-parent"))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-b").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", "ws-parent"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -107,16 +107,10 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
}
} else {
rows, err := h.database.QueryContext(ctx,
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
}
}
// Root workspaces (parent_id IS NULL) have no siblings: each org has
// exactly one root. Querying `parent_id IS NULL` would leak every org
// root across tenants (security #1953).
// Children
{
+3 -5
View File
@@ -94,15 +94,13 @@ func CanCommunicate(callerID, targetID string) bool {
return false
}
// Siblings — same parent (including root-level where both have no parent)
// Siblings — same parent (excluding root-level; each org has exactly one
// root workspace, so two distinct root-level workspaces are in different
// orgs and must not communicate cross-tenant).
if caller.ParentID != nil && target.ParentID != nil &&
*caller.ParentID == *target.ParentID {
return true
}
// Root-level siblings — both have no parent
if caller.ParentID == nil && target.ParentID == nil {
return true
}
// Direct parent → child (fast path; avoids the ancestor walk)
if target.ParentID != nil && caller.ID == *target.ParentID {
@@ -63,14 +63,16 @@ func TestCanCommunicate_Siblings(t *testing.T) {
}
}
func TestCanCommunicate_RootSiblings(t *testing.T) {
func TestCanCommunicate_Denied_RootCrossTenant(t *testing.T) {
mock := setupMockDB(t)
// Both at root level (no parent)
// Both at root level (no parent) but in different orgs.
// parent_id = NULL means org root; each org has exactly one root,
// so two distinct root workspaces must NOT communicate.
expectLookup(mock, "ws-a", nil)
expectLookup(mock, "ws-b", nil)
if !CanCommunicate("ws-a", "ws-b") {
t.Error("root-level siblings should communicate")
if CanCommunicate("ws-a", "ws-b") {
t.Error("distinct root-level workspaces should NOT communicate cross-tenant")
}
}