Skip to content
Merged
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
8 changes: 7 additions & 1 deletion docs/guides/MODULE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ Wires all modules, sets up session manager and SDK bridge, dispatches messages.
| `project-connection.js` | WebSocket connection setup, initial state sync, session restore, presence |
| `project-http.js` | All HTTP routes: image serving, file upload, push, skills, git status, info |
| `project-image.js` | `hydrateImageRefs`, `saveImageFile`, image directory setup |
| `project-file-watch.js` | File and directory fs.watch wrappers |
| `project-file-watch.js` | File and directory fs.watch wrappers; watches belong to one connection and read as that connection's user |
| `project-access.js` | The mode-aware principal resolver: "no user" is the implicit owner in single-user mode and a stranger in multi-user mode; project access (fails closed), admin check, the per-user project-list filter, and the session-read rule |
| `project-message-gate.js` | The per-message access gate at the entry of `handleMessage`: authorizes the current project and any `targetSlug`, strips the slug before handlers run |
| `project-file-scope.js` | File policy shared by the WS file handlers, `GET /api/file` and the watchers: fileBrowser permission, OS-identity requirement, one path resolver, reads and listings as the caller |
| `project-git.js` | Read-only git for a connected client: hex-only revisions, run as the repository's owner (never the viewer), repo-config programs switched off |
| `extension-commands.js` | Commands sent to the browser extension; each remembers the socket it went to, and only that socket may answer it |
| `ws-origin.js` | WebSocket upgrade origin check: host and port against the request host, operator allow-list |
| `sdk-bridge.js` | SDK bridge coordinator: createSDKBridge factory, worker lifecycle, query stream, tool permissions, mention sessions |
| `prompt-registry.js` | Sole owner of every operator-prompt lifecycle (permission, plan, AskUserQuestion, MCP elicitation, browser-extension command): open/answer/cancel/expire, sub-agent ownership, session grants, notification dismissal, replay state |
| `prompt-kinds/` | One adapter per prompt kind: payload fields, response parsing and validation against the request (answer bounds in `answer-limits.js`), the vendor callback's value for an answer and for an unanswered end; `codecs.js` loads the answer codecs and own-key lookups from the browser's own modules (`public/modules/prompt-kinds/`) |
Expand Down
26 changes: 26 additions & 0 deletions docs/guides/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,32 @@ graph TB

User provisioning lives in `daemon.js` (`provisionLinuxUser`, `grantProjectAccess`). All worker spawns route through `os-users.resolveOsUserInfo`.

## Access Control and Input Trust

Authorization is not opt-in per handler. One resolver (`lib/project-access.js`) answers "who is this, and may they use that project?" for every check, and an answer that cannot be given is a refusal: no lookup wired, an unknown slug, a lookup that throws, or a mode that cannot be read all deny, administrators included. A project record with no `visibility` is private; only `"public"` opens a project to everyone. A worktree has no record of its own and takes its parent's (`daemon-projects.js` `getProjectAccessRecord`).

**Authentication precedes, and is separate from, principal resolution.** `principalFor(user, { authenticated })` (and `canAccess`, `isAdmin`, `filterProjectList`, `canReadSession`, which take the same last argument) yields the implicit owner for a missing user only when the caller proved the login: `isRequestAuthed(req)` for an HTTP route, the WebSocket upgrade's cookie/ticket/login check (recorded on the connection as `_clagenticAuthenticated` and read back with `authOf(ws)`), or the local MCP bridge. Without the proof a missing user is nobody in every mode, so a route that forgets its login check refuses instead of serving the owner's data. A route that resolves a principal passes the proof it has; it never assumes one.

**Given the proof, "no user" is decided by the daemon's mode (`users.isMultiUser()`), never by absence alone.** In single-user mode a connection that got past the login gate carries no user record (`_clagenticUser === null`): the connection is the implicit owner, with admin rights and access to every project. In multi-user mode no user means unauthenticated and is refused. Every gate, filter and route asks the resolver instead of dereferencing the user, so a missing user never throws. A project context built without a resolver (unit harnesses) is single-user.

| Where | What it does |
|---|---|
| WebSocket upgrade, HTTP project routes, root redirect | `projectAccess.canAccess(userId, slug, proof)`; the local MCP bridge POST is the one request that carries no login. Every outcome of an upgrade answers or destroys the socket, including an unexpected error |
| `lib/project-message-gate.js`, called first by `handleMessage` | re-checks the connection's user against this project on every message; a `targetSlug` is authorized, resolved to a project that exists, and removed before any handler sees the message; a frame that is not a JSON object is dropped |
| Project lists (`projects_updated`, `info`, hub schedules) | filtered per client: `broadcastAll` turns a `projects_updated` into one filtered message per client, so no call site filters for itself |
| `GET /api/palette/search` | `appHandler` has no login check ahead of it, so the route proves authentication itself (`isRequestAuthed(req)`) before a principal is resolved; no proof is a 401 in every mode |
| Palette results, session rename, delete and search, file history | the session's own owner/visibility rule (`canReadSession`) is applied before anything is read |

Global settings (the global CLAUDE.md, shared environment variables) are administrator-only. The per-project environment messages act on the project the message arrived in, never on a slug the message carries.

**File policy** (`lib/project-file-scope.js`) is shared by the WebSocket `fs_*` handlers, `GET /api/file` and the watchers. The `fileBrowser` permission applies to every `fs_*` message except `fs_unwatch`. A path is resolved once, by one function that refuses non-strings, null bytes and anything outside the project after symlink resolution. In os-users mode a user with no resolved OS identity is refused; there is no fallback to a read as the daemon user. Watches (`project-file-watch.js`) belong to the connection that opened them: a change is read as that user and sent to that connection only, and a watch ends when its connection does.

Git history, diff and file-at-commit (`lib/project-git.js`) take only hex revisions, so a client value can never be read as a git option, and only project-relative paths. A repository's own config can name programs (textconv, `diff.external`, `core.fsmonitor`, hooks), so read-only git runs, in os-users mode, as the uid that owns the repository (the stat of the nearest `.git` found by walking up from the project directory, so a project in a repository subdirectory works), never as the viewer; viewer authorization is the access gate's job. Outside os-users mode git runs as the daemon. No `safe.directory` override is passed, a repository whose owner cannot be determined is refused, and every call passes `-c core.fsmonitor=false -c core.hooksPath=/dev/null -c diff.external=` and, for `diff`/`show`/`log`, `--no-ext-diff --no-textconv`; only committed blobs are compared.

**WebSocket origin** (`lib/ws-origin.js`): a request with no `Origin` header (CLI, relay, MCP bridge) is accepted, since the login still applies. Otherwise the origin's host and port must equal the request's `Host` header (or `X-Forwarded-Host` when `trustedProxy` is set), or the origin must be in `allowedOrigins` in `daemon.json` (for example `["https://console.example.com", "chrome-extension://<id>"]`). Scheme is compared only where the daemon knows it: it terminates TLS itself, or `trustedProxy` reports `X-Forwarded-Proto`. Extension origins are refused unless listed.

**Names that come from outside** (vendor names, tab ids, MCP server names and call ids, request ids, file names shown in the file tree) never index a plain object: tables keyed by them are prototype-less maps, vendor names are checked against the vendors the daemon can build, and a result for an extension or MCP call is accepted only from the socket the call was sent to.

## Operator Prompts

Every tool call goes through a vendor-neutral approval gate, and every other point where an agent waits on the operator (plan approval, AskUserQuestion, an MCP elicitation) uses the same machinery.
Expand Down
38 changes: 38 additions & 0 deletions lib/daemon-projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,42 @@ function getFilteredRemovedProjects(config, userId) {
});
}

/**
* The parent project's slug for a registered worktree slug, or null when the
* slug is not a registered worktree. Worktrees inherit their parent's access.
*/
function getWorktreeParent(wtSlug) {
var parents = Object.keys(worktreeRegistry);
for (var i = 0; i < parents.length; i++) {
if (worktreeRegistry[parents[i]].indexOf(wtSlug) !== -1) return parents[i];
}
return null;
}

/**
* The access record for a project slug, as lib/server.js's access checks read
* it: { slug, visibility, allowedUsers, ownerId }, or { error } when no
* project has that slug. A worktree is not in config.projects, so it carries
* its parent's record (a registered worktree whose parent is gone has none).
* A project with no stored visibility is private in os-users mode and public
* otherwise; lib/users-permissions.js treats anything not "public" as private.
*/
function getProjectAccessRecord(config, slug) {
var accessSlug = getWorktreeParent(slug) || slug;
var projects = config.projects || [];
for (var i = 0; i < projects.length; i++) {
if (projects[i].slug === accessSlug) {
return {
slug: slug,
visibility: projects[i].visibility || (config.osUsers ? "private" : "public"),
allowedUsers: projects[i].allowedUsers || [],
ownerId: projects[i].ownerId || null,
};
}
}
return { error: "Project not found" };
}

/**
* Register a worktree slug under a parent slug.
* Used by daemon.js when creating worktrees directly.
Expand Down Expand Up @@ -161,4 +197,6 @@ module.exports = {
getFilteredRemovedProjects: getFilteredRemovedProjects,
registerWorktreeSlug: registerWorktreeSlug,
unregisterWorktreeSlug: unregisterWorktreeSlug,
getWorktreeParent: getWorktreeParent,
getProjectAccessRecord: getProjectAccessRecord,
};
17 changes: 5 additions & 12 deletions lib/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var promptRegistry = require("./prompt-registry");
var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = require("./os-users");
var usersModule = require("./users");
var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug, getProjectAccessRecord } = require("./daemon-projects");
var { validateCloneUrl, buildCloneArgs } = require("./clone-validate");
var { DEFAULT_MEM_AVAILABLE_MIN_MB, DEFAULT_TOKENS_PER_MB_HEADROOM, getActiveLiveCount, buildActivityDiagnosticsResponse } = require("./sdk-bridge");
var { validateMemAvailableThresholdMB, validateTokensPerMbHeadroom } = require("./memory-setting-validate");
Expand Down Expand Up @@ -291,6 +291,9 @@ var relay = createServer({
// from a reverse proxy (e.g. Caddy). Never read the header unconditionally —
// see lib/effective-protocol.js.
trustedProxy: !!config.trustedProxy,
// Origins besides the daemon's own host that may open a WebSocket
// (daemon.json allowedOrigins, e.g. ["https://console.example.com"]).
allowedOrigins: Array.isArray(config.allowedOrigins) ? config.allowedOrigins : [],
port: config.port,
debug: config.debug || false,
dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
Expand Down Expand Up @@ -1357,17 +1360,7 @@ var relay = createServer({
return { error: "Project not found" };
},
onGetProjectAccess: function (slug) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
return {
slug: slug,
visibility: config.projects[i].visibility || (config.osUsers ? "private" : "public"),
allowedUsers: config.projects[i].allowedUsers || [],
ownerId: config.projects[i].ownerId || null,
};
}
}
return { error: "Project not found" };
return getProjectAccessRecord(config, slug);
},
onUserProvisioned: function (userId, linuxUser) {
// Grant ACL on all public projects to the newly provisioned user
Expand Down
45 changes: 45 additions & 0 deletions lib/extension-commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Commands the daemon sends to a connected browser extension, and the rule for
// who may answer them.
//
// A command is an "extension" prompt in the prompt registry; the extension
// answers with an extension_result naming the command's requestId. Any client
// of the project can send that message, so each command remembers the socket
// it went to and only that socket's answer is accepted.

var { promptsFor } = require("./prompt-registry");

/**
* @param {object} deps
* @param {function(): object} deps.getSessionManager the session manager whose
* prompt registry opens the commands (read per command: it may not exist yet
* when this is created)
* @param {function(object, object)} deps.sendTo (ws, message)
*/
function createExtensionCommands(deps) {
var targets = new Map(); // requestId -> ws

// Resolves with the extension's result, or null after the timeout.
function send(ws, command, args, timeout) {
var opened = promptsFor(deps.getSessionManager()).open(null, "extension", { command: command, args: args }, { timeoutMs: timeout });
targets.set(opened.requestId, ws);
var forget = function () { targets.delete(opened.requestId); };
opened.answer.then(forget, forget);
deps.sendTo(ws, {
type: "extension_command",
command: command,
args: args,
requestId: opened.requestId,
});
return opened.answer;
}

// Whether `ws` is the socket command `requestId` was sent to. Any other
// requestId, of any type, is not.
function mayAnswer(requestId, ws) {
return targets.get(requestId) === ws;
}

return { send: send, mayAnswer: mayAnswer };
}

module.exports = { createExtensionCommands: createExtensionCommands };
14 changes: 8 additions & 6 deletions lib/mcp-local.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ var CLAY_CONFIG_PATH = path.join(CONFIG_DIR, "mcp.json");
}());

function createLocalMcp() {
var _configCache = {}; // name -> { command, args, env, url }
var _processes = {}; // name -> { proc, buffer, ready, tools, pendingInit }
var _pendingRequests = {}; // rpcId -> { callId, resolve, reject, timer }
var _initCallbacks = {}; // rpcId -> { name, phase }
// Keyed by server names (from config files and clients) and by ids a child
// process echoes back, so none of these may inherit from Object.prototype.
var _configCache = Object.create(null); // name -> { command, args, env, url }
var _processes = Object.create(null); // name -> { proc, buffer, ready, tools, pendingInit }
var _pendingRequests = Object.create(null); // rpcId -> { callId, resolve, reject, timer }
var _initCallbacks = Object.create(null); // rpcId -> { name, phase }
var _jsonRpcId = 1;
var _initialized = false;
var _onServersReady = null; // callback when server list changes
Expand Down Expand Up @@ -60,7 +62,7 @@ function createLocalMcp() {

function getMergedServers() {
var config = readConfig();
var merged = Object.assign({}, config.mcpServers || {});
var merged = Object.assign(Object.create(null), config.mcpServers || {});

var includes = config.include || [];
for (var i = 0; i < includes.length; i++) {
Expand Down Expand Up @@ -270,7 +272,7 @@ function createLocalMcp() {
for (var i = 0; i < names.length; i++) {
killServer(names[i]);
}
_processes = {};
_processes = Object.create(null);
_initialized = false;
}

Expand Down
Loading
Loading