diff --git a/.changeset/quiet-wombats-share.md b/.changeset/quiet-wombats-share.md new file mode 100644 index 000000000..79194295f --- /dev/null +++ b/.changeset/quiet-wombats-share.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Let admins restrict regular users to selected linked workspaces while keeping each user's sessions private. diff --git a/CLAUDE.md b/CLAUDE.md index adb384009..7a023ddf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,7 +298,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `shared-workspaces.json` (admin-managed multi-user workspace grants, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/README.md b/README.md index f496be073..befb9ef7d 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,7 @@ codeman users list - **Per-user spaces** — each user's cases live under `~/codeman-users//cases`; sessions, cases, search, and real-time events are scoped to their owner. Admins see everything. - **Individually revocable logins** — named users with scrypt-hashed passwords in `~/.codeman/users.json`; disable, reset (one-time password), or delete an account at any time. Admin actions are audited to `~/.codeman/admin-audit.jsonl`. +- **Selected shared workspaces** — admins can grant a regular user access to linked local projects from App Settings → Users → Workspaces. The user cannot create projects, sees only granted workspaces, and keeps private sessions there; admins still see all sessions. Existing bypass-permissions and shell grants stay effective, so this prevents accidental project selection rather than acting as an OS sandbox. - **Safer defaults for regular users** — non-admins run Claude in `--permission-mode auto` (Anthropic's classifier-guarded mode); raw shell sessions, cron `launchCommand`, and skip-permissions require an explicit per-user grant. > ⚠️ **This separates workspaces; it does not sandbox users from each other.** Every session runs as the same OS account, so a determined user's agent can still reach another user's files. For real isolation, pair users with **Docker cases** or run separate instances under separate OS accounts. See [`docs/multi-user-plan.md`](docs/multi-user-plan.md) and the multi-user section of [`docs/security-architecture.md`](docs/security-architecture.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 38dd8e25e..d4e0999d9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -536,6 +536,7 @@ codeman users list - **按用户的空间**:每个用户的案例位于 `~/codeman-users//cases`;会话、案例、搜索与实时事件都按属主隔离。管理员可以看到全部。 - **可单独吊销的登录**:命名用户的密码以 scrypt 哈希保存在 `~/.codeman/users.json`;可随时禁用、重置(一次性密码)或删除账号。管理员操作审计记录在 `~/.codeman/admin-audit.jsonl`。 +- **指定共享工作区**:管理员可在 App Settings → Users → Workspaces 中,把已链接的本地项目授权给普通用户。该用户只能在 CodeMan 界面中看到获准项目,不能通过界面新建项目;在共享项目中的会话仍归启动者所有,管理员可查看所有会话。已有的绕过权限与 shell 授权仍然有效,因此这是防止误选项目的应用层限制,不是操作系统沙箱。 - **普通用户的更安全默认值**:非管理员以 `--permission-mode auto` 运行 Claude(Anthropic 的分类器护栏模式);raw shell 会话、cron `launchCommand` 与跳过权限模式需要按用户显式授权。 > ⚠️ **这只是工作空间的划分,不是用户之间的沙箱。** 所有会话都以同一个操作系统账户运行,因此有心用户的智能体依然能触及他人的文件。若需要真正的隔离,请结合 **Docker 案例**,或在不同的操作系统账户下运行独立实例。参见 [`docs/multi-user-plan.md`](docs/multi-user-plan.md) 与 [`docs/security-architecture.md`](docs/security-architecture.md) 的多用户章节。 diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index b4fb3cae7..f041cecd7 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -77,7 +77,7 @@ Roughly 235 handlers across 26 route modules. By domain: | Orchestrator | 10 | Plans and phases. | | Ralph | 9 | Loop control and configuration. | | Cron | 9 | Jobs and run history. | -| Admin | 8 | Multi-user administration. | +| Admin | 11 | Multi-user accounts and shared-workspace grants. | | Plan | 8 | Plan orchestration. | | Respawn | 7 | Respawn configuration and presets. | | Webviews | 6 | Saved dashboards, plus the proxy. | diff --git a/docs/wiki/Multi-User-Mode.md b/docs/wiki/Multi-User-Mode.md index 7299f9f1e..b57618341 100644 --- a/docs/wiki/Multi-User-Mode.md +++ b/docs/wiki/Multi-User-Mode.md @@ -60,6 +60,26 @@ Admins see everything. Ownership threads through every list endpoint, the session lookup helper, the WebSocket layer, and file previews. A user cannot address another user's session even by id. +## Sharing a selected workspace + +An admin can open **App Settings → Users → Workspaces** on a regular user's row and select +one or more already linked local projects. Saving the selection restricts that account to +those project directories, including their subdirectories. Its private case space and other +linked, remote, and Docker cases are hidden; creating or cloning projects is disabled. Saving +an empty selection blocks workspace access until the admin grants a project. **Remove +restriction** restores the normal private case space. + +The admin can link an existing project from **New Case → Link Existing** before granting it. +Saving or changing a user's grants stops live sessions whose working directories are no longer +allowed. Sessions in a shared project still belong to the user who started them; other regular +users cannot see or control those sessions, while admins can. + +This is still application-level path confinement. All sessions run as the same OS account, so +an agent allowed to run arbitrary host commands can access files outside Codeman's UI rules. +An existing **bypass-permissions grant remains effective** for a restricted user, as does shell +mode. Use this feature to prevent accidental project selection; use separate OS accounts or +Docker isolation when users must not be able to cross that boundary. + ## Safer defaults for regular users Non-admins get tighter defaults, and lifting them is an explicit per-user grant: @@ -86,6 +106,8 @@ at any time, and existing browser sessions can be revoked. - **Enabling it does not migrate existing cases** into a user space. They stay where they are, owned by whoever the ownership rules resolve them to. - **Admins see everything**, including other users' sessions. Choose admins accordingly. +- **Shared project files are common.** Users may have private sessions in the same workspace, + but edits to that project's files are visible to every user granted access. - **The audit log is append-only and local.** Ship it somewhere if you care about it. - **It is not a substitute for OS accounts.** Restating this because it is the one thing people get wrong. diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index 8dbaaa388..3dfc79553 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -66,7 +66,7 @@ be wrong for at least one of them: | -------------------- | -------------------------------------------------------------------------------------------- | | **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. | | **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. | -| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. | +| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode regular users get their own space; restricted users get only their assigned linked workspaces. | | **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. | Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index fe7835013..91cd20586 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -164,9 +164,9 @@ key clears the unsent prompt, and never sends the agent's `/clear` command. This is a separate file-serving surface from the viewer, with its own rules: it allowlists your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and -blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a -root, because per-user spaces live inside the home directory and a home-directory root would -expose everyone. +blocks sensitive trees. In multi-user mode a regular user gets only their own user space as a +root. An admin-restricted user instead gets only the linked local workspaces assigned to them; +host-wide and configured roots are omitted. ## Images into a session diff --git a/src/shared-workspace-store.ts b/src/shared-workspace-store.ts new file mode 100644 index 000000000..6eb1a3e41 --- /dev/null +++ b/src/shared-workspace-store.ts @@ -0,0 +1,109 @@ +/** + * @fileoverview Admin-managed access grants for shared local workspaces. + * + * Grants refer to names in linked-cases.json so an unlink also removes the + * workspace from a user's effective access without leaving a stale path grant. + */ + +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { dataPath, getDataDir } from './config/instance.js'; + +export interface SharedWorkspacePolicy { + restricted: true; + workspaceNames: string[]; +} + +type WorkspacePolicies = Record; + +const POLICIES_FILE = dataPath('shared-workspaces.json'); +const LINKED_CASES_FILE = dataPath('linked-cases.json'); + +function normalizeUsername(username: string): string { + return username.trim().toLowerCase(); +} + +function readJsonObject(path: string): Record { + try { + const value: unknown = JSON.parse(readFileSync(path, 'utf8')); + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; + } catch { + return {}; + } +} + +function readPolicies(): WorkspacePolicies { + const raw = readJsonObject(POLICIES_FILE); + const result: WorkspacePolicies = {}; + for (const [username, value] of Object.entries(raw)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const record = value as Record; + if (record.restricted !== true || !Array.isArray(record.workspaceNames)) continue; + const workspaceNames = [ + ...new Set(record.workspaceNames.filter((name): name is string => typeof name === 'string')), + ]; + result[normalizeUsername(username)] = { restricted: true, workspaceNames }; + } + return result; +} + +export function getSharedWorkspacePolicy(username: string): SharedWorkspacePolicy | undefined { + return readPolicies()[normalizeUsername(username)]; +} + +export function getSharedWorkspaceNames(username: string): string[] { + return getSharedWorkspacePolicy(username)?.workspaceNames ?? []; +} + +export function isWorkspaceRestricted(username: string): boolean { + return getSharedWorkspacePolicy(username)?.restricted === true; +} + +export function getSharedWorkspacePaths(username: string): string[] { + const names = new Set(getSharedWorkspaceNames(username)); + if (!names.size) return []; + const linked = readJsonObject(LINKED_CASES_FILE); + return [...names] + .map((name) => linked[name]) + .filter((path): path is string => typeof path === 'string' && path.length > 0); +} + +export function readLinkedWorkspacePaths(): Record { + const linked = readJsonObject(LINKED_CASES_FILE); + return Object.fromEntries( + Object.entries(linked).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ); +} + +export function writeSharedWorkspacePolicy(username: string, workspaceNames: string[]): void { + const normalized = normalizeUsername(username); + const policies = readPolicies(); + policies[normalized] = { restricted: true, workspaceNames: [...new Set(workspaceNames)] }; + writePolicies(policies); +} + +export function deleteSharedWorkspacePolicy(username: string): void { + const normalized = normalizeUsername(username); + const policies = readPolicies(); + if (!(normalized in policies)) return; + delete policies[normalized]; + writePolicies(policies); +} + +function writePolicies(policies: WorkspacePolicies): void { + mkdirSync(getDataDir(), { recursive: true }); + const tempFile = `${POLICIES_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; + try { + writeFileSync(tempFile, JSON.stringify(policies, null, 2), { mode: 0o600 }); + renameSync(tempFile, POLICIES_FILE); + chmodSync(POLICIES_FILE, 0o600); + } finally { + if (existsSync(tempFile)) { + try { + unlinkSync(tempFile); + } catch { + /* best effort cleanup */ + } + } + } +} diff --git a/src/web/public/admin-ui.js b/src/web/public/admin-ui.js index 90c0901d5..ebae7b9f5 100644 --- a/src/web/public/admin-ui.js +++ b/src/web/public/admin-ui.js @@ -411,6 +411,7 @@ + ${u.role === 'user' ? '' : ''} @@ -427,6 +428,7 @@ const username = b.closest('tr').dataset.u; b.onclick = () => { if (b.dataset.act === 'cases') return toggleCaseDrawer(username); + if (b.dataset.act === 'workspaces') return openWorkspaceModal(username); return panelAction( username, b.dataset.act, @@ -441,6 +443,68 @@ } } + async function openWorkspaceModal(username) { + let modal = document.getElementById('adminWorkspaceModal'); + if (!modal) { + modal = document.createElement('div'); + modal.className = 'modal'; + modal.id = 'adminWorkspaceModal'; + modal.style.zIndex = '3200'; + document.body.appendChild(modal); + } + modal.innerHTML = ``; + modal.style.display = 'flex'; + const endpoint = `/api/admin/users/${encodeURIComponent(username)}/workspaces`; + const setMessage = (message) => { + const el = modal.querySelector('#adminWorkspaceMessage'); + if (el) el.textContent = message; + }; + modal.querySelector('[data-workspace-action="close"]').onclick = () => (modal.style.display = 'none'); + modal.querySelector('[data-workspace-action="save"]').onclick = async () => { + const workspaceNames = [...modal.querySelectorAll('input[name="sharedWorkspace"]:checked')].map( + (input) => input.value + ); + const r = await apiSend('PUT', endpoint, { workspaceNames }); + if (!r.ok) return setMessage((r.body && r.body.error) || 'Could not update workspaces.'); + setMessage(`Saved. Stopped ${r.data.stoppedSessions || 0} session(s) outside these workspaces.`); + }; + modal.querySelector('[data-workspace-action="clear"]').onclick = async () => { + if (!window.confirm(`Remove ${username}'s workspace restriction?`)) return; + const r = await apiSend('DELETE', endpoint); + if (!r.ok) return setMessage((r.body && r.body.error) || 'Could not remove restriction.'); + setMessage('Restriction removed. Their private case space is available again.'); + }; + try { + const data = await apiGet(endpoint); + const selected = new Set(data.workspaceNames || []); + const options = modal.querySelector('#adminWorkspaceOptions'); + if (!data.available.length) { + options.textContent = 'No linked local workspaces are available. Link a project first, then reopen this panel.'; + return; + } + options.innerHTML = + `

Current policy: ${data.restricted ? 'Restricted' : 'Unrestricted'}

` + + data.available + .map( + ( + workspace + ) => `` + ) + .join(''); + } catch { + modal.querySelector('#adminWorkspaceOptions').textContent = 'Failed to load linked workspaces.'; + } + } + async function panelAction(username, act, u) { const path = `/api/admin/users/${encodeURIComponent(username)}`; if (act === 'role') { diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 75a92fcb2..11f9339da 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -31,6 +31,7 @@ import type { StaleExpirationMap } from '../utils/index.js'; import { dataPath } from '../config/instance.js'; import { getCasesDir } from '../config/cases-dir.js'; import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js'; +import { getSharedWorkspacePaths, isWorkspaceRestricted } from '../shared-workspace-store.js'; import { SYNTHETIC_ADMIN, findUser } from '../user-store.js'; import { AGENT_ORIGIN_SPAWNED_BY_SESSION, normalizeAgentOrigin } from '../agent-case-marker.js'; @@ -190,7 +191,9 @@ export function resolveCasesDir(user?: AuthUser): string { */ export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean { if (!isMultiUserMode() || user.role === 'admin') return true; - const base = userCasesDir(user.username); + const restricted = isWorkspaceRestricted(user.username); + const bases = restricted ? getSharedWorkspacePaths(user.username) : [userCasesDir(user.username)]; + if (bases.length === 0) return false; // Resolve the deepest existing ancestor to defeat symlink escapes without // requiring the leaf to exist yet. const resolveExisting = (p: string): string => { @@ -206,18 +209,22 @@ export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean } } }; - let realBase: string; - try { - realBase = realpathSync(base); - } catch { - // base does not exist yet — create it so confinement has a stable anchor - mkdirSync(base, { recursive: true }); - realBase = realpathSync(base); - } const realTarget = resolveExisting(workingDir); - if (realTarget === realBase) return true; - const rel = relative(realBase, realTarget); - return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); + for (const base of bases) { + let realBase: string; + try { + realBase = realpathSync(base); + } catch { + if (restricted) continue; + // Create the ordinary per-user case space so first-time users can start a case. + mkdirSync(base, { recursive: true }); + realBase = realpathSync(base); + } + if (realTarget === realBase) return true; + const rel = relative(realBase, realTarget); + if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true; + } + return false; } /** @@ -331,7 +338,19 @@ function sessionNotFoundError(sessionId: string): Error & { statusCode: number; export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session { const session = ctx.sessions.get(sessionId); if (!session) throw sessionNotFoundError(sessionId); - if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId); + if (req) { + const user = getAuthUser(req); + if (!canAccessOwned(user, session.owner)) throw sessionNotFoundError(sessionId); + if (session.owner === user.username) { + if ( + session.remote + ? user.role === 'user' && isWorkspaceRestricted(user.username) + : !isWorkingDirAllowed(user, session.workingDir) + ) { + throw sessionNotFoundError(sessionId); + } + } + } return session; } @@ -351,7 +370,19 @@ export function findPersistedSessionOrFail( ): SessionState { const persisted = store.getSession(sessionId); if (!persisted) throw sessionNotFoundError(sessionId); - if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId); + if (req) { + const user = getAuthUser(req); + if (!canAccessOwned(user, persisted.owner)) throw sessionNotFoundError(sessionId); + if (persisted.owner === user.username) { + if ( + persisted.remote + ? user.role === 'user' && isWorkspaceRestricted(user.username) + : !isWorkingDirAllowed(user, persisted.workingDir) + ) { + throw sessionNotFoundError(sessionId); + } + } + } return persisted; } diff --git a/src/web/routes/admin-routes.ts b/src/web/routes/admin-routes.ts index 582006e15..d5d369ef4 100644 --- a/src/web/routes/admin-routes.ts +++ b/src/web/routes/admin-routes.ts @@ -11,6 +11,7 @@ * DELETE /api/admin/users/:username * GET /api/admin/users/:username/cases (list the user's case folders) * DELETE /api/admin/users/:username/cases/:caseName (delete one case folder) + * GET/PUT/DELETE /api/admin/users/:username/workspaces (inspect, restrict, or restore access) * * Self-service GET /api/me + POST /api/me/password live in me-routes.ts. */ @@ -34,7 +35,7 @@ import { updateUser, UserStoreError, } from '../../user-store.js'; -import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js'; +import { getAuthUser, isWorkingDirAllowed, requireAdmin, revokeUserSessions } from '../route-helpers.js'; import { webviewCapabilities } from '../../webview-capabilities.js'; import { htmlViewCapabilities } from '../../html-view-capabilities.js'; import { appendAdminAudit } from '../admin-audit.js'; @@ -42,6 +43,12 @@ import { SseEvent } from '../sse-events.js'; import type { AuthPort } from '../ports/auth-port.js'; import type { SessionPort } from '../ports/session-port.js'; import type { EventPort } from '../ports/event-port.js'; +import { + deleteSharedWorkspacePolicy, + getSharedWorkspacePolicy, + readLinkedWorkspacePaths, + writeSharedWorkspacePolicy, +} from '../../shared-workspace-store.js'; const CreateUserSchema = z.object({ username: z.string().min(1).max(64), @@ -55,6 +62,9 @@ const UpdateUserSchema = z.object({ canBypassPermissions: z.boolean().optional(), }); const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() }); +const WorkspacePolicySchema = z.object({ + workspaceNames: z.array(z.string().regex(/^[^./\\][^/\\]{0,127}$/)).max(100), +}); // Case-folder names: a single path segment, no separators, no leading dot (hidden // dirs like .claude are infrastructure, not cases), so '.'/'..' are excluded too. @@ -109,6 +119,107 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut }; }); + app.get('/api/admin/users/:username/workspaces', async (req, reply) => { + if (!gate(req, reply)) return; + const username = normalizeUsername((req.params as { username: string }).username); + const user = await findUser(username); + if (!user) { + reply.code(404); + return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'User not found'); + } + const linked = readLinkedWorkspacePaths(); + const availableByPath = new Map(); + for (const [name, path] of Object.entries(linked)) { + if (!SAFE_CASE_NAME.test(name)) continue; + try { + const [realPath, stat] = await Promise.all([fsp.realpath(path), fsp.stat(path)]); + if (stat.isDirectory() && !availableByPath.has(realPath)) + availableByPath.set(realPath, { name, path: realPath }); + } catch { + // Ignore stale or unavailable linked folders. + } + } + const policy = getSharedWorkspacePolicy(username); + return { + success: true, + data: { + username, + role: user.role, + restricted: policy?.restricted ?? false, + workspaceNames: policy?.workspaceNames ?? [], + available: [...availableByPath.values()], + }, + }; + }); + + app.put('/api/admin/users/:username/workspaces', async (req, reply) => { + if (!gate(req, reply)) return; + const username = normalizeUsername((req.params as { username: string }).username); + const parsed = WorkspacePolicySchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input'); + } + const user = await findUser(username); + if (!user) { + reply.code(404); + return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'User not found'); + } + if (user.role !== 'user') { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Workspace restrictions apply to regular users only'); + } + const linked = readLinkedWorkspacePaths(); + const invalid = parsed.data.workspaceNames.find((name) => !(name in linked)); + if (invalid) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Workspace is not linked: ${invalid}`); + } + for (const name of parsed.data.workspaceNames) { + try { + if (!(await fsp.stat(linked[name])).isDirectory()) throw new Error('not a directory'); + } catch { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Workspace is unavailable: ${name}`); + } + } + + writeSharedWorkspacePolicy(username, parsed.data.workspaceNames); + let stoppedSessions = 0; + for (const session of [...ctx.sessions.values()]) { + if (session.owner !== username) continue; + if (!session.remote && isWorkingDirAllowed({ username, role: 'user' }, session.workingDir)) continue; + try { + await ctx.cleanupSession(session.id, true, 'workspace_access_revoked'); + stoppedSessions++; + } catch { + /* best effort; authorization checks still deny access to the session */ + } + } + audit(req, 'user.workspaces.update', username, { + workspaceNames: parsed.data.workspaceNames, + stoppedSessions, + }); + ctx.broadcast(SseEvent.AdminUsersChanged, {}); + return { + success: true, + data: { restricted: true, workspaceNames: parsed.data.workspaceNames, stoppedSessions }, + }; + }); + + app.delete('/api/admin/users/:username/workspaces', async (req, reply) => { + if (!gate(req, reply)) return; + const username = normalizeUsername((req.params as { username: string }).username); + if (!(await findUser(username))) { + reply.code(404); + return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'User not found'); + } + deleteSharedWorkspacePolicy(username); + audit(req, 'user.workspaces.clear', username); + ctx.broadcast(SseEvent.AdminUsersChanged, {}); + return { success: true, data: { username, restricted: false } }; + }); + app.post('/api/admin/users', async (req, reply) => { if (!gate(req, reply)) return; const parsed = CreateUserSchema.safeParse(req.body); @@ -203,6 +314,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut // record, so it is safe to read before the delete.) const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id); await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND (no side effects) + deleteSharedWorkspacePolicy(username); for (const id of owned) { await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {}); } diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 66a073f04..ca581c888 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -67,10 +67,11 @@ import { readJsonConfig, } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; +import { getSharedWorkspaceNames, isWorkspaceRestricted } from '../../shared-workspace-store.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js'; -import type { FastifyRequest } from 'fastify'; +import type { FastifyReply, FastifyRequest } from 'fastify'; import { dataPath, getDataDir } from '../../config/instance.js'; import { checkDockerAvailable, @@ -130,6 +131,13 @@ const APP_VERSION = (() => { const LOCAL_CLONE_ADMIN_ONLY = 'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.'; +function refuseRestrictedProjectCreation(req: FastifyRequest, reply?: FastifyReply): ApiResponse | null { + const user = getAuthUser(req); + if (!isMultiUserMode() || user.role === 'admin' || !isWorkspaceRestricted(user.username)) return null; + reply?.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Project creation is disabled for this account'); +} + /** * Whether a clone or preflight must run with git's credential helpers cleared: * a non-admin in multi-user mode. Every user's git runs as the one server @@ -197,9 +205,14 @@ async function readLinkedCases(): Promise> { */ async function resolveCasePath(name: string, user?: AuthUser): Promise { const linkedCases = await readLinkedCases(); - // Linked cases carry no owner (legacy/admin-only registry): a non-admin must not - // resolve arbitrary linked paths by name in multi-user mode (path-escape guard). - if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin')) return linkedCases[name]; + const sharedNames = user?.role === 'user' ? getSharedWorkspaceNames(user.username) : []; + if (user?.role === 'user' && isWorkspaceRestricted(user.username)) { + if (sharedNames.includes(name) && linkedCases[name]) return linkedCases[name]; + return join(resolveCasesDir(user), '.codeman-workspace-not-granted', name); + } + if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin' || sharedNames.includes(name))) { + return linkedCases[name]; + } return join(resolveCasesDir(user), name); } @@ -256,36 +269,37 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const cases: CaseInfo[] = []; const user = getAuthUser(req); const admin = isAdmin(req); - // Non-admins enumerate their OWN case space; admins see the shared CASES_DIR. + const restricted = user.role === 'user' && isWorkspaceRestricted(user.username); + // Restricted users see only assigned workspaces; others use their own space. const listBase = resolveCasesDir(user); - // Get cases from the user's (or shared) cases dir - try { - const entries = await fs.readdir(listBase, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) { - const casePath = join(listBase, e.name); - // Only a directory Codeman scaffolded for an agent spawn carries a marker, - // so this stays absent for every human-created, linked or cloned case. - const marker = await readAgentCaseMarker(casePath); - cases.push({ - name: e.name, - path: casePath, - hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')), - location: 'local', - ...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}), - }); + if (user.role === 'admin' || !isWorkspaceRestricted(user.username)) { + try { + const entries = await fs.readdir(listBase, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) { + const casePath = join(listBase, e.name); + const marker = await readAgentCaseMarker(casePath); + cases.push({ + name: e.name, + path: casePath, + hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')), + location: 'local', + ...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}), + }); + } } + } catch { + // dir may not exist yet } - } catch { - // dir may not exist yet } - // Linked cases (v1 registry has no owner) are admin-only in multi-user mode. + // Linked cases are visible to admins and users with an explicit workspace grant. const linkedCases = await readLinkedCases(); const existingNames = new Set(cases.map((c) => c.name)); - if (admin) { - for (const [name, path] of Object.entries(linkedCases)) { + const sharedNames = new Set(getSharedWorkspaceNames(user.username)); + for (const [name, path] of Object.entries(linkedCases)) { + if (admin || sharedNames.has(name)) { if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue; const state = await probePath(path); if (state === 'absent') continue; @@ -305,7 +319,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Get remote cases (owner-scoped; legacy no-owner = admin-only) const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host])); - for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) { + for (const remoteCase of restricted ? [] : await readRemoteCases(CODEMAN_CONFIG_DIR)) { const host = remoteHostMap.get(remoteCase.hostId); if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue; if (!admin && !canAccessOwned(user, remoteCase.owner)) continue; @@ -333,7 +347,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Get docker cases const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR); const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host])); - for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) { + for (const dockerCase of restricted ? [] : await readDockerCases(CODEMAN_CONFIG_DIR)) { const host = dockerHostMap.get(dockerCase.hostId); if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue; if (!admin && !canAccessOwned(user, dockerCase.owner)) continue; @@ -396,6 +410,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config */ app.get('/api/cases/agent-created', async (req): Promise> => { const user = getAuthUser(req); + if (user.role !== 'admin' && isWorkspaceRestricted(user.username)) { + return { success: true, data: { cases: [] } }; + } const listBase = resolveCasesDir(user); const inUsePaths = new Set( Array.from(ctx.sessions.values()) @@ -528,6 +545,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.post('/api/cases', async (req, reply): Promise> => { const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body); + const creationRefusal = refuseRestrictedProjectCreation(req, reply); + if (creationRefusal) return creationRefusal; // A custom folder writes outside the cases directory and registers the path in the shared, // ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link. @@ -642,6 +661,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config > => { const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body); const user = getAuthUser(req); + const creationRefusal = refuseRestrictedProjectCreation(req, reply); + if (creationRefusal) return creationRefusal; const parsed = parseGitRepositoryUrl(repository); if (!parsed.cloneable) return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.message); @@ -837,6 +858,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }); app.post('/api/cases/remote-link', async (req): Promise> => { + const creationRefusal = refuseRestrictedProjectCreation(req); + if (creationRefusal) return creationRefusal; const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const, owner: ownerFor(req) }; const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); const host = hosts.find((item) => item.id === remoteCase.hostId); @@ -922,6 +945,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config ): Promise< ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }> > => { + const creationRefusal = refuseRestrictedProjectCreation(req); + if (creationRefusal) return creationRefusal; const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const, @@ -1214,6 +1239,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config > => { const body = parseBody(DockerQuickCreateSchema, req.body); const { name, description } = body; + const creationRefusal = refuseRestrictedProjectCreation(req); + if (creationRefusal) return creationRefusal; const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req))); if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); @@ -1405,6 +1432,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Import a bundle (already present in the exports dir) into a NEW docker case. app.post('/api/docker-cases/import', async (req): Promise> => { + const creationRefusal = refuseRestrictedProjectCreation(req); + if (creationRefusal) return creationRefusal; const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body); const bundlePath = join(DOCKER_EXPORTS_DIR, bundle); if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir'); @@ -1583,11 +1612,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.delete('/api/cases/:name', async (req): Promise> => { const { name } = req.params as { name: string }; const user = getAuthUser(req); + if (user.role === 'user' && isWorkspaceRestricted(user.username)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Project management is disabled for this account'); + } if (!validatePathWithinBase(name, resolveCasesDir(user))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } - // Fold ownership INTO the match (don't early-return): a non-owned same-named remote/ // docker case is skipped so control falls through to the caller's own local delete. // canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical. @@ -1700,7 +1731,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // (remote/docker names are globally unique, local names per-user). No metadata is // disclosed for a foreign case. canAccessOwned is allow-all for admins/single-user. const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); - const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)); + const user = getAuthUser(req); + const restricted = user.role === 'user' && isWorkspaceRestricted(user.username); + const remoteCase = restricted + ? undefined + : remoteCases.find((item) => item.name === name && canAccessOwned(user, item.owner)); if (remoteCase) { const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); @@ -1718,9 +1753,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }; } - const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find( - (item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner) - ); + const dockerCase = restricted + ? undefined + : (await readDockerCases(CODEMAN_CONFIG_DIR)).find( + (item) => item.name === name && canAccessOwned(user, item.owner) + ); if (dockerCase) { const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 395e6af20..5591be9c8 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -51,6 +51,11 @@ import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../docume import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js'; +import { + getSharedWorkspaceNames, + isWorkspaceRestricted, + readLinkedWorkspacePaths, +} from '../../shared-workspace-store.js'; import { CASES_DIR, canAccessOwned, @@ -872,6 +877,12 @@ function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> { function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> { const user = getAuthUser(req); if (isMultiUserMode() && user.role !== 'admin') { + if (isWorkspaceRestricted(user.username)) { + const linked = readLinkedWorkspacePaths(); + return getSharedWorkspaceNames(user.username) + .filter((name) => typeof linked[name] === 'string') + .map((name) => ({ label: name, path: linked[name] })); + } return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()]; } return [ diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index ae068b285..297793ba7 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -24,6 +24,7 @@ import { parseBody, } from '../route-helpers.js'; import { resolveClaudeModeForUsername } from '../../user-store.js'; +import { isWorkspaceRestricted } from '../../shared-workspace-store.js'; import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { buildRalphLoopPrompt } from '../../prompts/index.js'; @@ -296,8 +297,13 @@ export function registerRalphRoutes( advisorModel, } = parseBody(RalphLoopStartSchema, req.body); + const user = getAuthUser(req); + if (user.role !== 'admin' && isWorkspaceRestricted(user.username)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Project creation is disabled for this account'); + } + // Multi-user: cases live in the requesting user's space. - const rlCasesBase = resolveCasesDir(getAuthUser(req)); + const rlCasesBase = resolveCasesDir(user); const casePath = join(rlCasesBase, caseName); // Security: Path traversal protection diff --git a/src/web/routes/scheduled-routes.ts b/src/web/routes/scheduled-routes.ts index 0d56fe081..b00f4fcc2 100644 --- a/src/web/routes/scheduled-routes.ts +++ b/src/web/routes/scheduled-routes.ts @@ -51,6 +51,9 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & // Multi-user: default a missing workingDir to the user's own cases dir rather // than the server's cwd. Single-user keeps process.cwd() (byte-identical). const effectiveWorkingDir = workingDir || (isMultiUserMode() ? resolveCasesDir(getAuthUser(req)) : process.cwd()); + if (!isWorkingDirAllowed(getAuthUser(req), effectiveWorkingDir)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is not within your allowed workspace'); + } const run = await ctx.startScheduledRun(prompt, effectiveWorkingDir, durationMinutes ?? 60, ownerFor(req)); return { run }; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc5533..728c9a188 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -121,6 +121,7 @@ import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; import { applyLaunchDefaults } from '../launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; import { isMultiUserMode } from '../../config/multiuser.js'; +import { getSharedWorkspaceNames, isWorkspaceRestricted } from '../../shared-workspace-store.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { writeHooksConfig, @@ -1013,7 +1014,11 @@ export function registerSessionRoutes( if (!isMultiUserMode()) return list; const user = getAuthUser(req); if (user.role === 'admin') return list; - return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner)); + return (list as Array<{ owner?: string; remote?: unknown; workingDir?: string }>).filter( + (s) => + canAccessOwned(user, s.owner) && + (!isWorkspaceRestricted(user.username) || (!s.remote && isWorkingDirAllowed(user, s.workingDir || ''))) + ); }); // ========== Legacy Session Tab Order (temporary synchronized compatibility bridge) ========== @@ -3700,13 +3705,14 @@ export function registerSessionRoutes( // per-user, so a name collision must not shadow the caller's own case. canAccessOwned // is allow-all for admins/single-user, so flag-OFF stays byte-identical. const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + const restricted = getAuthUser(req).role === 'user' && isWorkspaceRestricted(getAuthUser(req).username); const remoteCase = remoteCases.find( - (item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner) + (item) => !restricted && item.name === caseName && canAccessOwned(getAuthUser(req), item.owner) ); const dockerCase = remoteCase ? undefined : (await readDockerCases(CODEMAN_CONFIG_DIR)).find( - (item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner) + (item) => !restricted && item.name === caseName && canAccessOwned(getAuthUser(req), item.owner) ); if (remoteCase) { const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId); @@ -3914,10 +3920,11 @@ export function registerSessionRoutes( } catch { // File missing or unparseable — treat as empty registry } - // Multi-user: the linked-cases registry is ownerless/global, so only admins may - // resolve a name to an arbitrary linked path. A non-admin resolves inside their - // OWN case space only (single-user: isAdmin true, so linked cases still honoured). - const linked = isAdmin(req) ? linkedCases[caseName] : undefined; + // Linked workspaces stay hidden unless the caller is an admin or has an + // explicit grant for this case name. + const user = getAuthUser(req); + const shared = getSharedWorkspaceNames(user.username).includes(caseName); + const linked = isAdmin(req) || shared ? linkedCases[caseName] : undefined; casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req))); if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');