Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-wombats-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Let admins restrict regular users to selected linked workspaces while keeping each user's sessions private.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<username>/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/<username>/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.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ codeman users list

- **Per-user spaces** — each user's cases live under `~/codeman-users/<name>/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).
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ codeman users list

- **按用户的空间**:每个用户的案例位于 `~/codeman-users/<name>/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) 的多用户章节。
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/HTTP-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
22 changes: 22 additions & 0 deletions docs/wiki/Multi-User-Mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/wiki/Working-With-Files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
109 changes: 109 additions & 0 deletions src/shared-workspace-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, SharedWorkspacePolicy>;

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<string, unknown> {
try {
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
} 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<string, unknown>;
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<string, string> {
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 */
}
}
}
}
Loading
Loading