diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index 381e1d9e..f90af54f 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -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/`) | diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index ff017e40..e6872721 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -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://"]`). 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. diff --git a/lib/daemon-projects.js b/lib/daemon-projects.js index dcda20e9..ae785f22 100644 --- a/lib/daemon-projects.js +++ b/lib/daemon-projects.js @@ -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. @@ -161,4 +197,6 @@ module.exports = { getFilteredRemovedProjects: getFilteredRemovedProjects, registerWorktreeSlug: registerWorktreeSlug, unregisterWorktreeSlug: unregisterWorktreeSlug, + getWorktreeParent: getWorktreeParent, + getProjectAccessRecord: getProjectAccessRecord, }; diff --git a/lib/daemon.js b/lib/daemon.js index 174d3578..b1b87b47 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -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"); @@ -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, @@ -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 diff --git a/lib/extension-commands.js b/lib/extension-commands.js new file mode 100644 index 00000000..596b9d08 --- /dev/null +++ b/lib/extension-commands.js @@ -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 }; diff --git a/lib/mcp-local.js b/lib/mcp-local.js index 2d27d9a0..f0458a1f 100644 --- a/lib/mcp-local.js +++ b/lib/mcp-local.js @@ -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 @@ -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++) { @@ -270,7 +272,7 @@ function createLocalMcp() { for (var i = 0; i < names.length; i++) { killServer(names[i]); } - _processes = {}; + _processes = Object.create(null); _initialized = false; } diff --git a/lib/project-access.js b/lib/project-access.js new file mode 100644 index 00000000..aadad28e --- /dev/null +++ b/lib/project-access.js @@ -0,0 +1,138 @@ +// The one place that answers "who is this, and may they touch that project?". +// +// Every caller (HTTP gate, WebSocket upgrade, per-message gate, project +// lists, palette, schedules, file and session handlers) goes through here so +// no site can forget the check, dereference a missing user, or treat a failed +// lookup as permission. +// +// AUTHENTICATION precedes and is separate from PRINCIPAL resolution. "No user" +// is the implicit owner only when the caller PROVED the request or connection +// was authenticated ({ authenticated: true }): isRequestAuthed(req) for HTTP, +// the WebSocket upgrade's auth check (carried on the connection by +// authOf(ws)), or the local MCP bridge. Without that proof no user is nobody, +// in every mode, so a route that forgets the login check refuses instead of +// serving the owner's data. +// +// Given the proof, whether "no user" means an owner or a stranger is decided +// by the MODE of the daemon, never by the absence alone: +// - single-user mode: the authenticated connection carries no user record, +// because the connection is the implicit owner. It is allowed everything, +// and every project. +// - multi-user mode: no user means unauthenticated, which is a refusal. +// A lookup that cannot be completed (no lookup wired, project unknown, lookup +// error, mode unknown) is a refusal, never an allow. +// +// deps: +// users module exposing canAccessProject(userId, access) and +// canAccessSession(userId, session, access) +// onGetProjectAccess function(slug) -> access object | { error } | falsy +// isMultiUser function() -> boolean; absent or throwing means +// multi-user, so a failure to learn the mode denies + +// The proof a caller hands in alongside a possibly-missing user. Only the +// boolean true counts. +function isProven(opts) { + return !!opts && opts.authenticated === true; +} + +// The proof carried by a WebSocket connection or an HTTP request object. The +// upgrade handler and the HTTP route set _clagenticAuthenticated only after +// their login check passed; anything else, including a bare object, is +// unproven. +function authOf(conn) { + return { authenticated: !!conn && conn._clagenticAuthenticated === true }; +} + +function createProjectAccess(deps) { + var users = deps.users; + var onGetProjectAccess = deps.onGetProjectAccess; + var isMultiUser = deps.isMultiUser; + + function multiUserMode() { + if (typeof isMultiUser !== "function") return true; + try { + return isMultiUser() !== false; + } catch (e) { + return true; + } + } + + // The principal behind a request or connection, or null when it must be + // refused. `user` is the authenticated user record, or null/undefined. + // { id, user, admin, implicit } + // id is null only for the implicit single-user owner, who needs + // opts.authenticated === true. + function principalFor(user, opts) { + if (user && typeof user === "object" && user.id) { + return { id: user.id, user: user, admin: user.role === "admin", implicit: false }; + } + if (user) return null; + if (!isProven(opts)) return null; + if (multiUserMode()) return null; + return { id: null, user: null, admin: true, implicit: true }; + } + + function isAdmin(user, opts) { + var principal = principalFor(user, opts); + return !!principal && principal.admin; + } + + // The access record for a slug, or null when it cannot be established. + function resolve(slug) { + if (typeof slug !== "string" || !slug) return null; + if (typeof onGetProjectAccess !== "function") return null; + var access; + try { + access = onGetProjectAccess(slug); + } catch (e) { + return null; + } + if (!access || access.error) return null; + return access; + } + + // userId is the id of an authenticated user, or null/undefined for none. + function canAccess(userId, slug, opts) { + var principal = principalFor(userId ? { id: userId } : null, opts); + if (!principal) return false; + if (principal.implicit) return typeof slug === "string" && slug !== ""; + var access = resolve(slug); + if (!access) return false; + return users.canAccessProject(principal.id, access) === true; + } + + // Entries are project status objects (anything with a .slug). + function filterProjectList(userId, list, opts) { + if (!Array.isArray(list)) return []; + if (!principalFor(userId ? { id: userId } : null, opts)) return []; + return list.filter(function (p) { + return !!p && canAccess(userId, p.slug, opts); + }); + } + + // Whether `user` may see `session`. Access to the owning project is settled + // before a message reaches a handler (the per-message gate in project.js), + // so only the session's own owner/visibility rules are applied here. No + // principal or no session is a refusal; the implicit owner reads everything. + function canReadSession(user, session, opts) { + var principal = principalFor(user, opts); + if (!principal || !session) return false; + if (principal.implicit) return true; + return users.canAccessSession(principal.id, session, { visibility: "public" }) === true; + } + + return { + principalFor: principalFor, + authOf: authOf, + isAdmin: isAdmin, + resolve: resolve, + canAccess: canAccess, + filterProjectList: filterProjectList, + canReadSession: canReadSession, + }; +} + +module.exports = { + createProjectAccess: createProjectAccess, + authOf: authOf, +}; diff --git a/lib/project-connection.js b/lib/project-connection.js index d07e760b..3aee8731 100644 --- a/lib/project-connection.js +++ b/lib/project-connection.js @@ -2,6 +2,7 @@ var fs = require("fs"); var path = require("path"); var usersModule = require("./users"); var userPresence = require("./user-presence"); +var { authOf } = require("./project-access"); var { getCodexConfig } = require("./codex-defaults"); var { promptsFor } = require("./prompt-registry"); // detectLite is cached per-project in project.js and passed via ctx.liteStatus. @@ -18,7 +19,7 @@ var { detectLite } = require("./lite-detect"); * hydrateImageRefs, broadcastClientCount, broadcastPresence, * getProjectList, getHubSchedules, loadContextSources, * handleMessage, handleDisconnection, - * stopFileWatch, stopAllDirWatches, + * stopFileWatch(ws), stopAllDirWatches(ws), * getProjectOwnerId, setProjectOwnerId, getLatestVersion, * getTitle, getProject */ @@ -121,7 +122,7 @@ function attachConnection(ctx) { // Send cached state var _userId = ws._clagenticUser ? ws._clagenticUser.id : null; - var _filteredProjects = getProjectList(_userId); + var _filteredProjects = getProjectList(_userId, authOf(ws)); var title = getTitle(); var project = getProject(); var ownerLocked = !!(osUsers && osUsers.length > 0 && /^\/home\/[^/]+\//.test(cwd)); @@ -168,7 +169,7 @@ function attachConnection(ctx) { sendTo(ws, { type: "term_list", terminals: tm.list(ws) }); // Context sources sent after session is resolved (per-session storage) sendTo(ws, { type: "notes_list", notes: nm.list() }); - sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() }); + sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules(_userId, authOf(ws)) }); _loop.sendConnectionState(ws); if (_mcp) _mcp.sendConnectionState(ws); if (_notifications) _notifications.sendConnectionState(ws, sendTo); @@ -319,10 +320,9 @@ function attachConnection(ctx) { } tm.detachAll(ws); clients.delete(ws); - if (clients.size === 0) { - stopFileWatch(); - stopAllDirWatches(); - } + // Watches belong to the connection that opened them. + stopFileWatch(ws); + stopAllDirWatches(ws); broadcastClientCount(); broadcastPresence(); } diff --git a/lib/project-file-scope.js b/lib/project-file-scope.js new file mode 100644 index 00000000..73377533 --- /dev/null +++ b/lib/project-file-scope.js @@ -0,0 +1,172 @@ +// File-scope policy shared by the WebSocket fs_* handlers, the HTTP file +// routes and the file/directory watchers: who may touch files, how a client +// supplied path is resolved, and how a file is read. +// +// Two rules hold everywhere this module is used: +// - the fileBrowser permission is checked before any file is touched; +// - in os-users mode a caller without a resolved OS identity is refused, +// never served by a privileged read as the daemon user. + +var fs = require("fs"); +var path = require("path"); +var { authOf } = require("./project-access"); + +function isUsablePath(requested) { + return typeof requested === "string" && requested.indexOf("\0") === -1; +} + +// Resolve `requested` inside `base`, following symlinks. Null for anything +// that is not a string, holds a null byte, or lands outside `base`. +function safePath(base, requested) { + if (!isUsablePath(requested)) return null; + var resolved = path.resolve(base, requested); + if (resolved !== base && !resolved.startsWith(base + path.sep)) return null; + try { + var real = fs.realpathSync(resolved); + if (real !== base && !real.startsWith(base + path.sep)) return null; + return real; + } catch (e) { + return null; + } +} + +// Resolve an absolute path without requiring it to be within a project root. +// Only used in os-users mode, where the kernel's ACLs decide access. +function safeAbsPath(requested) { + if (!requested || !isUsablePath(requested)) return null; + var resolved = path.resolve(requested); + try { + return fs.realpathSync(resolved); + } catch (e) { + return null; + } +} + +// Resolve a path inside the Claude data directory (~/.claude/), read-only +// fallback so plan files that live outside the project can be previewed. +function safeClaudePath(requested) { + if (!requested || !isUsablePath(requested)) return null; + var claudeDir = path.join(require("./config").REAL_HOME, ".claude"); + var resolved = path.resolve(requested); + if (resolved !== claudeDir && !resolved.startsWith(claudeDir + path.sep)) return null; + try { + var real = fs.realpathSync(resolved); + if (real !== claudeDir && !real.startsWith(claudeDir + path.sep)) return null; + return real; + } catch (e) { + return null; + } +} + +// Returns an error string when the caller may not use file features, else null. +// user the authenticated user, if any +// osUsers whether os-users mode is on +// osUserInfo the caller's resolved OS identity, or null +// usersModule module exposing getEffectivePermissions(user, osUsers) +function checkFileAccess(o) { + if (o.user) { + var perms = o.usersModule.getEffectivePermissions(o.user, o.osUsers); + if (!perms.fileBrowser) return "File browser access is not permitted"; + } + if (o.osUsers && !o.osUserInfo) return "File access requires an OS user identity"; + return null; +} + +// Path a caller may read or write: inside the project, or (os-users) wherever +// the OS grants that identity access. Used by writes and by the HTTP file route. +function resolveProjectPath(cwd, requested, osUserInfo) { + var resolved = safePath(cwd, requested); + if (!resolved && osUserInfo) resolved = safeAbsPath(requested); + return resolved; +} + +// Path a caller may read over the WebSocket: as above, plus read-only access +// inside ~/.claude (plan files). +function resolveReadPath(cwd, requested, osUserInfo) { + return resolveProjectPath(cwd, requested, osUserInfo) || safeClaudePath(requested); +} + +// A project-relative path for git, or null when the request is not a path +// that stays inside the project. The file need not exist (history can name +// deleted files), so this is lexical rather than a realpath check. +function resolveGitPath(cwd, requested) { + if (!requested || !isUsablePath(requested)) return null; + var abs = path.resolve(cwd, requested); + if (abs === cwd || !abs.startsWith(cwd + path.sep)) return null; + return path.relative(cwd, abs).split(path.sep).join("/"); +} + +// Read a file as the caller. Result kind: "text", "binary" or "too_large". +// file, osUsers, osUserInfo, fsAsUser, maxSize, binaryExts +function readFileForUser(file, o) { + if (o.osUsers && !o.osUserInfo) throw new Error("File access requires an OS user identity"); + var ext = path.extname(file).toLowerCase(); + var size = o.osUserInfo + ? o.fsAsUser("stat", { file: file }, o.osUserInfo).size + : fs.statSync(file).size; + if (size > o.maxSize) return { kind: "too_large", size: size, ext: ext }; + if (o.binaryExts.has(ext)) return { kind: "binary", size: size, ext: ext }; + var content = o.osUserInfo + ? o.fsAsUser("read", { file: file, readContent: true }, o.osUserInfo).content + : fs.readFileSync(file, "utf8"); + return { kind: "text", size: size, content: content, ext: ext }; +} + +// List a directory as the caller: [{ name, isDir }]. +function listDirForUser(dir, o) { + if (o.osUsers && !o.osUserInfo) throw new Error("File access requires an OS user identity"); + if (o.osUserInfo) { + return o.fsAsUser("list", { dir: dir }, o.osUserInfo).map(function (e) { + return { name: e.name, isDir: !!e.isDir }; + }); + } + return fs.readdirSync(dir, { withFileTypes: true }).map(function (d) { + return { name: d.name, isDir: d.isDirectory() }; + }); +} + +// Binds the policy above to one project's settings so the WebSocket handlers +// and the watchers ask the same questions of the same code. +// osUsers, usersModule, fsAsUser, binaryExts, maxSize +// projectAccess the lib/project-access.js resolver: a connection with no +// principal (no user in multi-user mode) is refused here +// as well, however it got this far +// getOsUserInfoForWs(ws) -> the connection's OS identity, or null +function createFileAccess(deps) { + function readOptions(ws) { + return { + osUsers: deps.osUsers, + osUserInfo: deps.getOsUserInfoForWs(ws), + fsAsUser: deps.fsAsUser, + maxSize: deps.maxSize, + binaryExts: deps.binaryExts, + }; + } + return { + // Error string when this connection may not use file features, else null. + authorize: function (ws) { + if (deps.projectAccess && !deps.projectAccess.principalFor(ws._clagenticUser, authOf(ws))) return "Authentication required"; + return checkFileAccess({ + user: ws._clagenticUser, + osUsers: deps.osUsers, + osUserInfo: deps.getOsUserInfoForWs(ws), + usersModule: deps.usersModule, + }); + }, + readFile: function (ws, file) { return readFileForUser(file, readOptions(ws)); }, + listDir: function (ws, dir) { return listDirForUser(dir, readOptions(ws)); }, + }; +} + +module.exports = { + createFileAccess: createFileAccess, + safePath: safePath, + safeAbsPath: safeAbsPath, + safeClaudePath: safeClaudePath, + checkFileAccess: checkFileAccess, + resolveReadPath: resolveReadPath, + resolveProjectPath: resolveProjectPath, + resolveGitPath: resolveGitPath, + readFileForUser: readFileForUser, + listDirForUser: listDirForUser, +}; diff --git a/lib/project-file-watch.js b/lib/project-file-watch.js index e44cb7e3..5bd0bdf4 100644 --- a/lib/project-file-watch.js +++ b/lib/project-file-watch.js @@ -1,111 +1,135 @@ var fs = require("fs"); var path = require("path"); +// Most directory watches one connection may hold; each is an inotify handle. +var MAX_DIR_WATCHES_PER_CONNECTION = 64; + /** - * Attach file/directory watcher engine to a project context. + * Attach the file/directory watcher engine to a project context. + * + * Watches belong to the connection that asked for them. Every change is read + * as that connection's user (an OS user in os-users mode) and is sent to that + * connection only, so a watch can neither read a file the user may not read + * nor show its contents to anyone else on the project. * * ctx fields: - * cwd, send, safePath, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS + * cwd, sendTo(ws, msg), safePath(base, requested) + * authorize(ws) -> error string | null (file-browser policy) + * readFile(ws, absFile) -> { kind: "text"|"binary"|"too_large", content, size } + * listDir(ws, absDir) -> [{ name, isDir }] + * IGNORED_DIRS */ function attachFileWatch(ctx) { var cwd = ctx.cwd; - var send = ctx.send; + var sendTo = ctx.sendTo; var safePath = ctx.safePath; - var BINARY_EXTS = ctx.BINARY_EXTS; - var FS_MAX_SIZE = ctx.FS_MAX_SIZE; + var authorize = ctx.authorize; + var readFile = ctx.readFile; + var listDir = ctx.listDir; var IGNORED_DIRS = ctx.IGNORED_DIRS; - // --- File watcher --- - var fileWatcher = null; - var watchedPath = null; - var watchDebounce = null; + // --- Single-file watch: at most one per connection --- + var fileWatches = new Map(); // ws -> { relPath, watcher, debounce } - function startFileWatch(relPath) { + function startFileWatch(ws, relPath) { + if (authorize(ws)) return; var absPath = safePath(cwd, relPath); if (!absPath) return; - if (watchedPath === relPath) return; - stopFileWatch(); - watchedPath = relPath; + var existing = fileWatches.get(ws); + if (existing && existing.relPath === relPath) return; + stopFileWatch(ws); + var entry = { relPath: relPath, watcher: null, debounce: null }; try { - fileWatcher = fs.watch(absPath, function () { - clearTimeout(watchDebounce); - watchDebounce = setTimeout(function () { + entry.watcher = fs.watch(absPath, function () { + clearTimeout(entry.debounce); + entry.debounce = setTimeout(function () { try { - var stat = fs.statSync(absPath); - var ext = path.extname(absPath).toLowerCase(); - if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return; - var content = fs.readFileSync(absPath, "utf8"); - send({ type: "fs_file_changed", path: relPath, content: content, size: stat.size }); + if (authorize(ws)) { stopFileWatch(ws); return; } + var result = readFile(ws, absPath); + if (result.kind !== "text") return; + sendTo(ws, { type: "fs_file_changed", path: relPath, content: result.content, size: result.size }); } catch (e) { - stopFileWatch(); + stopFileWatch(ws); } }, 200); }); - fileWatcher.on("error", function () { stopFileWatch(); }); + entry.watcher.on("error", function () { stopFileWatch(ws); }); + fileWatches.set(ws, entry); } catch (e) { - watchedPath = null; + // The file vanished or cannot be watched; the client simply gets no events. } } - function stopFileWatch() { - if (fileWatcher) { - try { fileWatcher.close(); } catch (e) {} - fileWatcher = null; - } - clearTimeout(watchDebounce); - watchDebounce = null; - watchedPath = null; + function stopFileWatch(ws) { + var entry = fileWatches.get(ws); + if (!entry) return; + clearTimeout(entry.debounce); + try { entry.watcher.close(); } catch (e) {} + fileWatches.delete(ws); } - // --- Directory watcher --- - var dirWatchers = {}; // relPath -> { watcher, debounce } + // --- Directory watches: any number per connection, up to the cap --- + var dirWatches = new Map(); // ws -> Map(relPath -> { watcher, debounce }) - function startDirWatch(relPath) { - if (dirWatchers[relPath]) return; + function startDirWatch(ws, relPath) { + if (authorize(ws)) return; + var mine = dirWatches.get(ws); + if (mine && mine.has(relPath)) return; + if (mine && mine.size >= MAX_DIR_WATCHES_PER_CONNECTION) return; var absPath = safePath(cwd, relPath); if (!absPath) return; + var entry = { watcher: null, debounce: null }; try { - var debounce = null; - var watcher = fs.watch(absPath, function () { - clearTimeout(debounce); - debounce = setTimeout(function () { - // Re-read directory and broadcast to all clients + entry.watcher = fs.watch(absPath, function () { + clearTimeout(entry.debounce); + entry.debounce = setTimeout(function () { try { - var items = fs.readdirSync(absPath, { withFileTypes: true }); + if (authorize(ws)) { stopDirWatch(ws, relPath); return; } var entries = []; + var items = listDir(ws, absPath); for (var i = 0; i < items.length; i++) { - if (items[i].isDirectory() && IGNORED_DIRS.has(items[i].name)) continue; + if (items[i].isDir && IGNORED_DIRS.has(items[i].name)) continue; entries.push({ name: items[i].name, - type: items[i].isDirectory() ? "dir" : "file", + type: items[i].isDir ? "dir" : "file", path: path.relative(cwd, path.join(absPath, items[i].name)).split(path.sep).join("/"), }); } - send({ type: "fs_dir_changed", path: relPath, entries: entries }); + sendTo(ws, { type: "fs_dir_changed", path: relPath, entries: entries }); } catch (e) { - stopDirWatch(relPath); + stopDirWatch(ws, relPath); } }, 300); }); - watcher.on("error", function () { stopDirWatch(relPath); }); - dirWatchers[relPath] = { watcher: watcher, debounce: debounce }; - } catch (e) {} + entry.watcher.on("error", function () { stopDirWatch(ws, relPath); }); + if (!mine) { mine = new Map(); dirWatches.set(ws, mine); } + mine.set(relPath, entry); + } catch (e) { + // Not watchable; listing still works, it just will not refresh itself. + } } - function stopDirWatch(relPath) { - var entry = dirWatchers[relPath]; - if (entry) { - clearTimeout(entry.debounce); - try { entry.watcher.close(); } catch (e) {} - delete dirWatchers[relPath]; - } + function stopDirWatch(ws, relPath) { + var mine = dirWatches.get(ws); + var entry = mine && mine.get(relPath); + if (!entry) return; + clearTimeout(entry.debounce); + try { entry.watcher.close(); } catch (e) {} + mine.delete(relPath); + if (mine.size === 0) dirWatches.delete(ws); } - function stopAllDirWatches() { - var paths = Object.keys(dirWatchers); - for (var i = 0; i < paths.length; i++) { - stopDirWatch(paths[i]); - } + // Every directory watch one connection holds; with stopFileWatch(ws) this + // releases everything the connection owns when it disconnects. + function stopAllDirWatches(ws) { + var mine = dirWatches.get(ws); + if (!mine) return; + Array.from(mine.keys()).forEach(function (relPath) { stopDirWatch(ws, relPath); }); + } + + function stopAll() { + Array.from(fileWatches.keys()).forEach(stopFileWatch); + Array.from(dirWatches.keys()).forEach(stopAllDirWatches); } return { @@ -114,6 +138,7 @@ function attachFileWatch(ctx) { startDirWatch: startDirWatch, stopDirWatch: stopDirWatch, stopAllDirWatches: stopAllDirWatches, + stopAll: stopAll, }; } diff --git a/lib/project-filesystem.js b/lib/project-filesystem.js index dcd84206..576918e8 100644 --- a/lib/project-filesystem.js +++ b/lib/project-filesystem.js @@ -1,6 +1,16 @@ var fs = require("fs"); var path = require("path"); -var execFileSync = require("child_process").execFileSync; +var fileScope = require("./project-file-scope"); +var projectGit = require("./project-git"); + +// Settings that act on the whole machine (not on this project), so the +// per-user projectSettings permission is not enough: administrators only. +var ADMIN_ONLY_TYPES = { + read_global_claude_md: true, + write_global_claude_md: true, + get_shared_env: true, + set_shared_env: true, +}; /** * Attach filesystem-related message handlers to a project context. @@ -9,13 +19,17 @@ var execFileSync = require("child_process").execFileSync; * cwd, slug, osUsers * sm (session manager) * send, sendTo - * safePath, safeAbsPath (functions) * getOsUserInfoForWs (function) - * startFileWatch, stopFileWatch, startDirWatch (from _fileWatch) - * usersModule, fsAsUser + * startFileWatch, stopFileWatch, startDirWatch (from _fileWatch; take the ws first) + * usersModule, projectAccess (lib/project-access.js resolver), fsAsUser * validateEnvString (function) * opts (for onGetProjectEnv, onSetProjectEnv, onGetSharedEnv, onSetSharedEnv callbacks) - * IGNORED_DIRS, BINARY_EXTS, IMAGE_EXTS, FS_MAX_SIZE (constants) + * fileAccess from project-file-scope.createFileAccess + * IGNORED_DIRS, IMAGE_EXTS (constants) + * + * The slug every handler acts on is ctx.slug: the per-message gate in + * project.js has already authorized it. A slug carried in a message is never + * used. */ function attachFilesystem(ctx) { var cwd = ctx.cwd; @@ -24,79 +38,72 @@ function attachFilesystem(ctx) { var sm = ctx.sm; var send = ctx.send; var sendTo = ctx.sendTo; - var safePath = ctx.safePath; - var safeAbsPath = ctx.safeAbsPath; - var safeClaudePath = ctx.safeClaudePath; var getOsUserInfoForWs = ctx.getOsUserInfoForWs; var startFileWatch = ctx.startFileWatch; var stopFileWatch = ctx.stopFileWatch; var startDirWatch = ctx.startDirWatch; var usersModule = ctx.usersModule; + var projectAccess = ctx.projectAccess; var fsAsUser = ctx.fsAsUser; var validateEnvString = ctx.validateEnvString; var opts = ctx.opts; var IGNORED_DIRS = ctx.IGNORED_DIRS; - var BINARY_EXTS = ctx.BINARY_EXTS; var IMAGE_EXTS = ctx.IMAGE_EXTS; - var FS_MAX_SIZE = ctx.FS_MAX_SIZE; + var fileAccess = ctx.fileAccess; + + // The file policy applies to every fs_* message except fs_unwatch, which + // only releases what the connection already holds. + function isFileMessage(type) { + return typeof type === "string" && type.indexOf("fs_") === 0 && type !== "fs_unwatch"; + } + + // Run read-only git inside the project as the repository's owner (see + // project-git.js); the connection was authorized before this handler ran. + function gitFor(args) { + return projectGit.runGit(args, { cwd: cwd, osUsers: osUsers }); + } function handleFilesystemMessage(ws, msg) { - // --- File browser permission gate --- - if (msg.type === "fs_list" || msg.type === "fs_read" || msg.type === "fs_write" || msg.type === "fs_delete" || msg.type === "fs_rename" || msg.type === "fs_mkdir" || msg.type === "fs_upload" || msg.type === "fs_search") { - if (ws._clagenticUser) { - var fbPerms = usersModule.getEffectivePermissions(ws._clagenticUser, osUsers); - if (!fbPerms.fileBrowser) { - sendTo(ws, { type: msg.type + "_result", error: "File browser access is not permitted" }); - return true; - } + // --- Settings that act beyond this project: administrators only --- + if (typeof msg.type === "string" && ADMIN_ONLY_TYPES[msg.type] === true) { + if (!projectAccess.isAdmin(ws._clagenticUser, projectAccess.authOf(ws))) { + sendTo(ws, { type: "error", text: "Admin access required" }); + return true; + } + } + + // --- File policy gate (fileBrowser permission, OS identity) --- + if (isFileMessage(msg.type)) { + var fileError = fileAccess.authorize(ws); + if (fileError) { + sendTo(ws, { type: msg.type + "_result", path: msg.path, error: fileError }); + return true; } } // --- fs_list --- if (msg.type === "fs_list") { - var fsDir = safePath(cwd, msg.path || "."); - // In OS user mode, fall back to absolute path resolution (ACL enforces access) - if (!fsDir && getOsUserInfoForWs(ws)) { - fsDir = safeAbsPath(msg.path); - } - // Allow read-only access to paths within ~/.claude/ (e.g. plans/) - if (!fsDir && safeClaudePath) { - fsDir = safeClaudePath(msg.path); - } + var fsListUserInfo = getOsUserInfoForWs(ws); + var fsDir = fileScope.resolveReadPath(cwd, msg.path || ".", fsListUserInfo); if (!fsDir) { sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: "Access denied" }); return true; } try { - var fsListUserInfo = getOsUserInfoForWs(ws); var entries = []; - if (fsListUserInfo) { - // Run as target OS user to respect Linux file permissions - var rawEntries = fsAsUser("list", { dir: fsDir }, fsListUserInfo); - for (var fi = 0; fi < rawEntries.length; fi++) { - var re = rawEntries[fi]; - if (re.isDir && IGNORED_DIRS.has(re.name)) continue; - entries.push({ - name: re.name, - type: re.isDir ? "dir" : "file", - path: path.relative(cwd, path.join(fsDir, re.name)).split(path.sep).join("/"), - }); - } - } else { - var items = fs.readdirSync(fsDir, { withFileTypes: true }); - for (var fi = 0; fi < items.length; fi++) { - var item = items[fi]; - if (item.isDirectory() && IGNORED_DIRS.has(item.name)) continue; - entries.push({ - name: item.name, - type: item.isDirectory() ? "dir" : "file", - path: path.relative(cwd, path.join(fsDir, item.name)).split(path.sep).join("/"), - }); - } + var items = fileAccess.listDir(ws, fsDir); + for (var fi = 0; fi < items.length; fi++) { + var item = items[fi]; + if (item.isDir && IGNORED_DIRS.has(item.name)) continue; + entries.push({ + name: item.name, + type: item.isDir ? "dir" : "file", + path: path.relative(cwd, path.join(fsDir, item.name)).split(path.sep).join("/"), + }); } sendTo(ws, { type: "fs_list_result", path: msg.path || ".", entries: entries }); // Auto-watch the directory for changes - startDirWatch(msg.path || "."); + startDirWatch(ws, msg.path || "."); } catch (e) { sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: e.message }); } @@ -105,7 +112,7 @@ function attachFilesystem(ctx) { // --- fs_search --- if (msg.type === "fs_search") { - var query = (msg.query || "").trim().toLowerCase(); + var query = (typeof msg.query === "string" ? msg.query : "").trim().toLowerCase(); if (!query) { sendTo(ws, { type: "fs_search_result", query: msg.query, entries: [] }); return true; @@ -113,19 +120,12 @@ function attachFilesystem(ctx) { try { var searchResults = []; var MAX_RESULTS = 50; - var searchUserInfo = getOsUserInfoForWs(ws); function walkDir(dir, relPrefix) { if (searchResults.length >= MAX_RESULTS) return; var items; try { - if (searchUserInfo) { - items = fsAsUser("list", { dir: dir }, searchUserInfo); - } else { - items = fs.readdirSync(dir, { withFileTypes: true }).map(function (d) { - return { name: d.name, isDir: d.isDirectory() }; - }); - } + items = fileAccess.listDir(ws, dir); } catch (e) { return; } for (var i = 0; i < items.length; i++) { if (searchResults.length >= MAX_RESULTS) return; @@ -151,50 +151,22 @@ function attachFilesystem(ctx) { // --- fs_read --- if (msg.type === "fs_read") { - var fsFile = safePath(cwd, msg.path); - if (!fsFile && getOsUserInfoForWs(ws)) { - fsFile = safeAbsPath(msg.path); - } - // Allow read-only access to paths within ~/.claude/ (e.g. plans/) - if (!fsFile && safeClaudePath) { - fsFile = safeClaudePath(msg.path); - } + var fsReadUserInfo = getOsUserInfoForWs(ws); + var fsFile = fileScope.resolveReadPath(cwd, msg.path, fsReadUserInfo); if (!fsFile) { sendTo(ws, { type: "fs_read_result", path: msg.path, error: "Access denied" }); return true; } try { - var fsReadUserInfo = getOsUserInfoForWs(ws); - var ext = path.extname(fsFile).toLowerCase(); - if (fsReadUserInfo) { - // Run stat and read as target OS user - var statResult = fsAsUser("stat", { file: fsFile }, fsReadUserInfo); - if (statResult.size > FS_MAX_SIZE) { - sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: statResult.size, error: "File too large (" + (statResult.size / 1024 / 1024).toFixed(1) + " MB)" }); - return true; - } - if (BINARY_EXTS.has(ext)) { - var result = { type: "fs_read_result", path: msg.path, binary: true, size: statResult.size }; - if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path); - sendTo(ws, result); - return true; - } - var readResult = fsAsUser("read", { file: fsFile, readContent: true }, fsReadUserInfo); - sendTo(ws, { type: "fs_read_result", path: msg.path, content: readResult.content, size: statResult.size }); + var readResult = fileAccess.readFile(ws, fsFile); + if (readResult.kind === "too_large") { + sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: readResult.size, error: "File too large (" + (readResult.size / 1024 / 1024).toFixed(1) + " MB)" }); + } else if (readResult.kind === "binary") { + var result = { type: "fs_read_result", path: msg.path, binary: true, size: readResult.size }; + if (IMAGE_EXTS.has(readResult.ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path); + sendTo(ws, result); } else { - var stat = fs.statSync(fsFile); - if (stat.size > FS_MAX_SIZE) { - sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: stat.size, error: "File too large (" + (stat.size / 1024 / 1024).toFixed(1) + " MB)" }); - return true; - } - if (BINARY_EXTS.has(ext)) { - var result = { type: "fs_read_result", path: msg.path, binary: true, size: stat.size }; - if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path); - sendTo(ws, result); - return true; - } - var content = fs.readFileSync(fsFile, "utf8"); - sendTo(ws, { type: "fs_read_result", path: msg.path, content: content, size: stat.size }); + sendTo(ws, { type: "fs_read_result", path: msg.path, content: readResult.content, size: readResult.size }); } } catch (e) { sendTo(ws, { type: "fs_read_result", path: msg.path, error: e.message }); @@ -204,10 +176,7 @@ function attachFilesystem(ctx) { // --- fs_write --- if (msg.type === "fs_write") { - var fsWriteFile = safePath(cwd, msg.path); - if (!fsWriteFile && getOsUserInfoForWs(ws)) { - fsWriteFile = safeAbsPath(msg.path); - } + var fsWriteFile = fileScope.resolveProjectPath(cwd, msg.path, getOsUserInfoForWs(ws)); if (!fsWriteFile) { sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" }); return true; @@ -228,8 +197,6 @@ function attachFilesystem(ctx) { // --- Project settings permission gate --- if (msg.type === "get_project_env" || msg.type === "set_project_env" || - msg.type === "read_global_claude_md" || msg.type === "write_global_claude_md" || - msg.type === "get_shared_env" || msg.type === "set_shared_env" || msg.type === "transfer_project_owner") { if (ws._clagenticUser) { var psPerms = usersModule.getEffectivePermissions(ws._clagenticUser, osUsers); @@ -245,26 +212,31 @@ function attachFilesystem(ctx) { var envrc = ""; var hasEnvrc = false; if (typeof opts.onGetProjectEnv === "function") { - var envResult = opts.onGetProjectEnv(msg.slug); + var envResult = opts.onGetProjectEnv(slug); envrc = envResult.envrc || ""; } try { var envrcPath = path.join(cwd, ".envrc"); hasEnvrc = fs.existsSync(envrcPath); } catch (e) {} - sendTo(ws, { type: "project_env_result", slug: msg.slug, envrc: envrc, hasEnvrc: hasEnvrc }); + sendTo(ws, { type: "project_env_result", slug: slug, envrc: envrc, hasEnvrc: hasEnvrc }); return true; } if (msg.type === "set_project_env") { if (typeof opts.onSetProjectEnv === "function") { - var envError = validateEnvString(msg.envrc || ""); + if (msg.envrc != null && typeof msg.envrc !== "string") { + sendTo(ws, { type: "set_project_env_result", ok: false, slug: slug, error: "Invalid value" }); + return true; + } + var envText = msg.envrc || ""; + var envError = validateEnvString(envText); if (envError) { - sendTo(ws, { type: "set_project_env_result", ok: false, slug: msg.slug, error: envError }); + sendTo(ws, { type: "set_project_env_result", ok: false, slug: slug, error: envError }); return true; } - var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || ""); - sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error }); + var setResult = opts.onSetProjectEnv(slug, envText); + sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: slug, error: setResult.error }); } else { sendTo(ws, { type: "set_project_env_result", ok: false, error: "Not supported" }); } @@ -311,6 +283,10 @@ function attachFilesystem(ctx) { if (msg.type === "set_shared_env") { if (typeof opts.onSetSharedEnv === "function") { + if (msg.envrc != null && typeof msg.envrc !== "string") { + sendTo(ws, { type: "set_shared_env_result", ok: false, error: "Invalid value" }); + return true; + } var sharedEnvError = validateEnvString(msg.envrc || ""); if (sharedEnvError) { sendTo(ws, { type: "set_shared_env_result", ok: false, error: sharedEnvError }); @@ -326,12 +302,12 @@ function attachFilesystem(ctx) { // --- File watcher --- if (msg.type === "fs_watch") { - if (msg.path) startFileWatch(msg.path); + if (msg.path) startFileWatch(ws, msg.path); return true; } if (msg.type === "fs_unwatch") { - stopFileWatch(); + stopFileWatch(ws); return true; } @@ -342,7 +318,12 @@ function attachFilesystem(ctx) { sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] }); return true; } - var absHistPath = path.resolve(cwd, histPath); + var histRelPath = fileScope.resolveGitPath(cwd, histPath); + if (!histRelPath) { + sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [], error: "Access denied" }); + return true; + } + var absHistPath = path.resolve(cwd, histRelPath); var entries = []; // Collect session edits. @@ -353,6 +334,8 @@ function attachFilesystem(ctx) { // disk read does not call loadSessionHistory() and does not mutate // session.history/_historyLoaded/LRU state. sm.sessions.forEach(function (session) { + // Edits made in a session the caller cannot read stay unseen. + if (!projectAccess.canReadSession(ws._clagenticUser, session, projectAccess.authOf(ws))) return; var sessionLocalId = session.localId; var sessionTitle = session.title || "Untitled"; var history = sm.readSessionHistoryFromDisk(session); @@ -459,10 +442,7 @@ function attachFilesystem(ctx) { // Collect git commits try { - var gitLog = execFileSync( - "git", ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath], - { cwd: cwd, encoding: "utf8", timeout: 5000 } - ); + var gitLog = gitFor(["log", "--format=%H|%at|%an|%s", "--follow", "--", histRelPath]); var gitLines = gitLog.trim().split("\n"); for (var gi = 0; gi < gitLines.length; gi++) { if (!gitLines[gi]) continue; @@ -496,14 +476,18 @@ function attachFilesystem(ctx) { sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" }); return true; } + // A revision is an object name, never anything git could read as an option. + var diffRelPath = fileScope.resolveGitPath(cwd, diffPath); + if (!projectGit.isCommitHash(hash) || (hash2 && !projectGit.isCommitHash(hash2)) || !diffRelPath) { + sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: "Invalid params" }); + return true; + } try { var diff; if (hash2) { - diff = execFileSync("git", ["diff", hash, hash2, "--", diffPath], - { cwd: cwd, encoding: "utf8", timeout: 5000 }); + diff = gitFor(["diff", hash, hash2, "--", diffRelPath]); } else { - diff = execFileSync("git", ["show", hash, "--format=", "--", diffPath], - { cwd: cwd, encoding: "utf8", timeout: 5000 }); + diff = gitFor(["show", hash, "--format=", "--", diffRelPath]); } sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" }); } catch (e) { @@ -520,12 +504,14 @@ function attachFilesystem(ctx) { sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" }); return true; } + // git show wants hash:repo-relative-path + var atRelPath = fileScope.resolveGitPath(cwd, atPath); + if (!projectGit.isCommitHash(atHash) || !atRelPath) { + sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Invalid params" }); + return true; + } try { - // Convert to repo-relative path (git show requires hash:relative/path) - var atAbsPath = path.resolve(cwd, atPath); - var atRelPath = path.relative(cwd, atAbsPath); - var content = execFileSync("git", ["show", atHash + ":" + atRelPath], - { cwd: cwd, encoding: "utf8", timeout: 5000 }); + var content = gitFor(["show", atHash + ":" + atRelPath]); sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content }); } catch (e) { sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message }); diff --git a/lib/project-git.js b/lib/project-git.js new file mode 100644 index 00000000..512d585a --- /dev/null +++ b/lib/project-git.js @@ -0,0 +1,119 @@ +// Running read-only git on behalf of a connected client. +// +// Client-supplied values never reach git unchecked: a revision must be a +// hex object name (so it cannot be read as an option), and a path must be +// project-relative (see project-file-scope.resolveGitPath). +// +// A repository's own config can name programs git will run (textconv, +// diff.external, core.fsmonitor, hooks). Those must only ever run with the +// authority of whoever owns the repository, never with a viewer's: +// - in os-users mode git runs as the uid that owns the repository (the +// stat of the .git found by walking up from the project directory, which +// may be a subdirectory of the repository), not as the connection's user. Who may look at the +// repository is settled by the access gate before a handler runs; +// - otherwise git runs as the daemon, as every other daemon action does; +// - on every call the config-driven programs are switched off, and only +// committed blobs are read, so no clean/smudge or textconv filter runs +// over working-tree content. +// No safe.directory override is ever passed: a repository whose owner git +// does not trust is refused by git itself. + +var fs = require("fs"); +var path = require("path"); +var childProcess = require("child_process"); + +var COMMIT_HASH_RE = /^[0-9a-fA-F]{4,64}$/; + +// Config that would otherwise let a repository run a program. +var HARDENING_CONFIG = [ + "-c", "core.fsmonitor=false", + "-c", "core.hooksPath=/dev/null", + "-c", "diff.external=", +]; + +// Subcommands that render diffs, and so consult textconv and external drivers. +var DIFF_RENDERING = { diff: true, show: true, log: true }; + +function isCommitHash(value) { + return typeof value === "string" && COMMIT_HASH_RE.test(value); +} + +// The directory holding the `.git` entry (a directory, or the file a worktree +// or submodule uses) that git would discover from `cwd`: the nearest ancestor, +// `cwd` included. Null when there is none. A project can live in a +// subdirectory of its repository, so `cwd/.git` alone is not the repository. +function findRepositoryRoot(cwd) { + var dir = path.resolve(cwd); + for (;;) { + try { + fs.statSync(path.join(dir, ".git")); + return dir; + } catch (e) { + if (e.code !== "ENOENT" && e.code !== "ENOTDIR") return null; + } + var parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// The uid/gid that owns the repository containing `cwd`. Throws when it cannot +// be established: an unreadable or missing repository is refused, never run as +// someone else. +function repositoryOwner(cwd) { + var root = findRepositoryRoot(cwd); + if (!root) throw new Error("Cannot determine the repository owner"); + var st; + try { + st = fs.statSync(path.join(root, ".git")); + } catch (e) { + throw new Error("Cannot determine the repository owner"); + } + return { uid: st.uid, gid: st.gid }; +} + +// The full argv for one call: hardening before the subcommand, and diff +// rendering told not to run any external or textconv program. +function buildArgs(args) { + var out = HARDENING_CONFIG.slice(); + if (!args.length) return out; + out.push(args[0]); + if (DIFF_RENDERING[args[0]] === true) out.push("--no-ext-diff", "--no-textconv"); + return out.concat(args.slice(1)); +} + +// args: git argv after the binary name +// o: { cwd, osUsers, timeoutMs, exec } +// exec replaces child_process.execFileSync (tests observe the identity used). +function runGit(args, o) { + var execOpts = { + cwd: o.cwd, + encoding: "utf8", + timeout: o.timeoutMs || 5000, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + GIT_TERMINAL_PROMPT: "0", + GIT_OPTIONAL_LOCKS: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + }; + if (o.osUsers) { + var owner = repositoryOwner(o.cwd); + execOpts.uid = owner.uid; + execOpts.gid = owner.gid; + } + var exec = o.exec || childProcess.execFileSync; + return exec("git", buildArgs(args), execOpts); +} + +module.exports = { + isCommitHash: isCommitHash, + findRepositoryRoot: findRepositoryRoot, + repositoryOwner: repositoryOwner, + buildArgs: buildArgs, + runGit: runGit, +}; diff --git a/lib/project-http.js b/lib/project-http.js index 5f608850..e739b9a6 100644 --- a/lib/project-http.js +++ b/lib/project-http.js @@ -7,6 +7,7 @@ var { fsAsUser } = require("./os-users"); var usersModule = require("./users"); var { promptsFor } = require("./prompt-registry"); var { mayAnswerPrompt } = require("./prompt-access"); +var fileScope = require("./project-file-scope"); var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]); var MIME_TYPES = { @@ -57,8 +58,8 @@ function parseJsonBody(req) { * Attach HTTP request handler to a project context. * * ctx fields: - * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule, - * safePath, safeAbsPath, getOsUserInfoForReq, sendExtensionCommandAny, + * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule, projectAccess, + * getOsUserInfoForReq, sendExtensionCommandAny, * _extToken, _browserTabList */ function attachHTTP(ctx) { @@ -70,8 +71,7 @@ function attachHTTP(ctx) { var imagesDir = ctx.imagesDir; var osUsers = ctx.osUsers; var pushModule = ctx.pushModule; - var safePath = ctx.safePath; - var safeAbsPath = ctx.safeAbsPath; + var projectAccess = ctx.projectAccess; var getOsUserInfoForReq = ctx.getOsUserInfoForReq; var sendExtensionCommandAny = ctx.sendExtensionCommandAny; var _extToken = ctx._extToken; @@ -280,15 +280,21 @@ function attachHTTP(ctx) { var params = new URLSearchParams(urlPath.substring(qIdx)); var reqFilePath = params.get("path"); if (!reqFilePath) { res.writeHead(400); res.end("Missing path"); return true; } - var absFile = safePath(cwd, reqFilePath); - if (!absFile && getOsUserInfoForReq(req)) { - absFile = safeAbsPath(reqFilePath); - } + // Same file policy as the WebSocket file messages: a caller without the + // fileBrowser permission, or without an OS identity in os-users mode, + // is refused rather than served by a read as the daemon user. + var fileServeUserInfo = getOsUserInfoForReq(req); + // No user is the single-user owner only for a request the server marked + // authenticated (or the local MCP bridge), else a stranger. + var fileAccessError = projectAccess.principalFor(req._clagenticUser, projectAccess.authOf(req)) + ? fileScope.checkFileAccess({ user: req._clagenticUser, osUsers: osUsers, osUserInfo: fileServeUserInfo, usersModule: usersModule }) + : "Authentication required"; + if (fileAccessError) { res.writeHead(403); res.end(fileAccessError); return true; } + var absFile = fileScope.resolveProjectPath(cwd, reqFilePath, fileServeUserInfo); if (!absFile) { res.writeHead(403); res.end("Access denied"); return true; } var fileExt = path.extname(absFile).toLowerCase(); if (!IMAGE_EXTS.has(fileExt)) { res.writeHead(403); res.end("Only image files"); return true; } try { - var fileServeUserInfo = getOsUserInfoForReq(req); var fileContent; if (fileServeUserInfo) { var binResult = fsAsUser("read_binary", { file: absFile }, fileServeUserInfo); diff --git a/lib/project-loop.js b/lib/project-loop.js index 4de02e57..ca4814c2 100644 --- a/lib/project-loop.js +++ b/lib/project-loop.js @@ -6,6 +6,7 @@ var { createLoopRegistry } = require("./scheduler"); var store = require("./store"); var usersModule = require("./users"); var { writeHandoff, buildHandoffPreamble } = require("./loop-handoff"); +var { authOf } = require("./project-access"); var LOOP_ID_RE = /^loop_[A-Za-z0-9_-]+$/; @@ -35,7 +36,7 @@ function validateLoopId(id) { * * ctx fields: * cwd, slug, sm, sdk, send, sendTo, sendToSession, pushModule, - * getHubSchedules, getAllProjectSessions, getSessionUnread, getStatus, + * getHubSchedules(userId), broadcastHubSchedules, getAllProjectSessions, getSessionUnread, getStatus, * getLinuxUserForSession, onProcessingChanged, * hydrateImageRefs */ @@ -50,6 +51,12 @@ function attachLoop(ctx) { var pushModule = ctx.pushModule; var notificationsModule = ctx.notificationsModule; var getHubSchedules = ctx.getHubSchedules; + // getHubSchedules(userId, proof) is limited to what that user may see; a + // context without a per-client broadcaster sends the list for no user and no + // proof of login (empty). + var broadcastHubSchedules = ctx.broadcastHubSchedules || function () { + send({ type: "loop_registry_updated", records: getHubSchedules(null) }); + }; var getAllProjectSessions = ctx.getAllProjectSessions; // lr-0aa7b6: per-session unread lookup — (ws, sessionProjectSlug, localId) => count. var getSessionUnread = ctx.getSessionUnread || function () { return 0; }; @@ -372,7 +379,7 @@ function attachLoop(ctx) { triggerFromQueue(record); }, onChange: function () { - send({ type: "loop_registry_updated", records: getHubSchedules() }); + broadcastHubSchedules(); }, }); loopRegistry.load(); @@ -1389,7 +1396,7 @@ function attachLoop(ctx) { // --- Hub: cross-project schedule aggregation --- if (msg.type === "hub_schedules_list") { - sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules() }); + sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules(ws._clagenticUser ? ws._clagenticUser.id : null, authOf(ws)) }); return true; } @@ -1403,7 +1410,7 @@ function attachLoop(ctx) { // recent sessions overall. getAllProjectSessions returns annotated shallow // copies carrying _projectSlug/_projectTitle/_projectIcon for every project. var hubUserId = ws._clagenticUser ? ws._clagenticUser.id : null; - var allSessions = getAllProjectSessions ? getAllProjectSessions(true, hubUserId) : []; + var allSessions = getAllProjectSessions ? getAllProjectSessions(true, hubUserId, authOf(ws)) : []; // Sort newest-first by lastActivity allSessions.sort(function (a, b) { return (b.lastActivity || b.createdAt || 0) - (a.lastActivity || a.createdAt || 0); @@ -1445,7 +1452,7 @@ function attachLoop(ctx) { // --- Loop Registry messages --- if (msg.type === "loop_registry_list") { - sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() }); + sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules(ws._clagenticUser ? ws._clagenticUser.id : null, authOf(ws)) }); return true; } diff --git a/lib/project-mcp.js b/lib/project-mcp.js index 9b3c78d2..e8ebd830 100644 --- a/lib/project-mcp.js +++ b/lib/project-mcp.js @@ -14,14 +14,18 @@ function attachMcp(ctx) { var getExtensionId = ctx.getExtensionId || function () { return null; }; var localMcp = ctx.localMcp || null; // mcp-local instance for localhost clients + // The three tables below are keyed by names and ids a client reports, so + // they have no prototype to answer for "constructor" or "__proto__". + // Available servers reported by extension: { name -> { name, transport, tools, enabled } } - var _availableServers = {}; + var _availableServers = Object.create(null); // Proxy MCP server objects for the SDK: { name -> sdkMcpServerConfig } - var _proxyServers = {}; + var _proxyServers = Object.create(null); - // Pending tool calls: { callId -> { resolve, reject, timer } } - var _pendingCalls = {}; + // Pending tool calls: { callId -> { resolve, reject, timer, ws } } + // `ws` is the extension socket the call was sent to; only it may answer. + var _pendingCalls = Object.create(null); var TOOL_TIMEOUT_MS = 30000; @@ -33,11 +37,11 @@ function attachMcp(ctx) { return true; } if (msg.type === "mcp_tool_result") { - handleToolResult(msg); + handleToolResult(ws, msg); return true; } if (msg.type === "mcp_tool_error") { - handleToolError(msg); + handleToolError(ws, msg); return true; } if (msg.type === "mcp_toggle_server") { @@ -50,15 +54,16 @@ function attachMcp(ctx) { var _remoteHostConnected = false; function handleServersAvailable(ws, msg) { - var servers = msg.servers || []; + var servers = Array.isArray(msg.servers) ? msg.servers : []; _remoteHostConnected = !!msg.hostConnected; - _availableServers = {}; + _availableServers = Object.create(null); for (var i = 0; i < servers.length; i++) { var s = servers[i]; + if (!s || typeof s !== "object" || typeof s.name !== "string") continue; _availableServers[s.name] = { name: s.name, transport: s.transport || "stdio", - tools: s.tools || [], + tools: Array.isArray(s.tools) ? s.tools : [], enabled: s.enabled !== false, }; } @@ -70,28 +75,32 @@ function attachMcp(ctx) { broadcastMcpState(); } - function handleToolResult(msg) { - var callId = msg.callId; - console.log("[mcp-bridge] Tool result received: " + callId); + // The call a result or error answers, if the socket that sent it is the + // socket the call went to; otherwise null and the pending call is untouched. + function takePendingCall(ws, callId) { + if (typeof callId !== "string") return null; var pending = _pendingCalls[callId]; - if (!pending) return; + if (!pending || pending.ws !== ws) return null; if (pending.timer) clearTimeout(pending.timer); delete _pendingCalls[callId]; + return pending; + } + + function handleToolResult(ws, msg) { + var pending = takePendingCall(ws, msg.callId); + if (!pending) return; pending.resolve(msg.result || { content: [{ type: "text", text: "(empty result)" }] }); } - function handleToolError(msg) { - var callId = msg.callId; - console.log("[mcp-bridge] Tool error received: " + callId + " error=" + (msg.error || "unknown")); - var pending = _pendingCalls[callId]; + function handleToolError(ws, msg) { + var pending = takePendingCall(ws, msg.callId); if (!pending) return; - if (pending.timer) clearTimeout(pending.timer); - delete _pendingCalls[callId]; - pending.reject(new Error(msg.error || "MCP tool call failed")); + pending.reject(new Error(typeof msg.error === "string" && msg.error ? msg.error : "MCP tool call failed")); } function handleToggleServer(ws, msg) { var name = msg.name; + if (typeof name !== "string" || !name) return; var enabled = !!msg.enabled; var list = getEnabledMcpServers() || []; @@ -111,7 +120,7 @@ function attachMcp(ctx) { // ---------- Proxy Server Builder ---------- function rebuildProxyServers() { - _proxyServers = {}; + _proxyServers = Object.create(null); var sdk; try { @@ -229,7 +238,7 @@ function attachMcp(ctx) { reject(new Error("MCP tool call timed out after " + (TOOL_TIMEOUT_MS / 1000) + "s")); }, TOOL_TIMEOUT_MS); - _pendingCalls[callId] = { resolve: resolve, reject: reject, timer: timer }; + _pendingCalls[callId] = { resolve: resolve, reject: reject, timer: timer, ws: extWs }; console.log("[mcp-bridge] Sending tool call: " + callId + " server=" + serverName + " tool=" + toolName); sendTo(extWs, { @@ -291,7 +300,7 @@ function attachMcp(ctx) { function buildMcpState() { var enabledList = getEnabledMcpServers() || []; var servers = []; - var seen = {}; + var seen = Object.create(null); // Remote servers (from Extension) var names = Object.keys(_availableServers); @@ -347,13 +356,13 @@ function attachMcp(ctx) { if (pending.timer) clearTimeout(pending.timer); pending.reject(new Error("MCP bridge disconnected")); } - _pendingCalls = {}; + _pendingCalls = Object.create(null); } function handleExtensionDisconnect() { cancelAllPending(); - _availableServers = {}; - _proxyServers = {}; + _availableServers = Object.create(null); + _proxyServers = Object.create(null); broadcastMcpState(); } diff --git a/lib/project-message-gate.js b/lib/project-message-gate.js new file mode 100644 index 00000000..f7a6b59e --- /dev/null +++ b/lib/project-message-gate.js @@ -0,0 +1,70 @@ +// The single access gate at the entry of project message handling. +// +// Before any handler sees a message, the gate settles two questions: +// 1. may this connection's user act on THIS project (access can change +// after the socket was opened), and +// 2. if the message names another project (targetSlug), may the user act +// on that one, and does it exist. +// A message is either refused here or handed on with the target resolved and +// the client-supplied slug removed, so handlers only ever act on a slug the +// gate authorized. +// +// deps: +// slug this project's slug +// canAccess(userId, slug, proof) -> boolean fail-closed project access +// check; a null userId is the implicit +// single-user owner only when `proof` +// ({ authenticated: true }, from the +// connection) says the upgrade login +// check passed, else a stranger +// (lib/project-access.js). Null for a +// bare context with no access wiring. +// getProject(slug) -> project context | null + +var { authOf } = require("./project-access"); + +var DENIED ="Project not found or access denied"; + +function createMessageGate(deps) { + var slug = deps.slug; + var canAccess = deps.canAccess; + var getProject = deps.getProject; + + function refuse(error) { + return { allowed: false, error: error || null, target: null, message: null }; + } + + function authorize(ws, msg) { + // Anything that is not a JSON object cannot be a message. + if (!msg || typeof msg !== "object" || Array.isArray(msg)) return refuse(null); + + var user = ws && ws._clagenticUser; + var userId = user ? user.id : null; + var wired = typeof canAccess === "function"; + + var proof = authOf(ws); + + if (wired && !canAccess(userId, slug, proof)) return refuse(DENIED); + + var named = msg.targetSlug; + var hasTarget = named !== undefined && named !== null && named !== ""; + if (!hasTarget) return { allowed: true, error: null, target: null, message: msg }; + + if (typeof named !== "string") return refuse(DENIED); + + var forwarded = Object.assign({}, msg); + delete forwarded.targetSlug; + if (named === slug) return { allowed: true, error: null, target: null, message: forwarded }; + + // Another project: both the existence and the right to act must hold. + if (!wired || typeof getProject !== "function") return refuse(DENIED); + if (!canAccess(userId, named, proof)) return refuse(DENIED); + var target = getProject(named); + if (!target) return refuse(DENIED); + return { allowed: true, error: null, target: target, message: forwarded }; + } + + return { authorize: authorize }; +} + +module.exports = { createMessageGate: createMessageGate, DENIED: DENIED }; diff --git a/lib/project-sessions.js b/lib/project-sessions.js index dd97b89c..2779289e 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -11,6 +11,8 @@ var agentsFavorites = require("./agents-favorites"); var sessionActivity = require("./session-activity"); var { promptsFor } = require("./prompt-registry"); var { attachPromptResponses } = require("./project-prompt-responses"); +var { ownValue } = require("./prompt-kinds/codecs"); +var { isKnownVendor } = require("./yoke"); // Kick off SDK agent discovery in the background at module load. // Errors are swallowed inside refresh(); the cache starts empty and fills // when the SDK subprocess completes initialization (~9s on this box). @@ -91,6 +93,7 @@ function attachSessions(ctx) { var sendToSessionOthers = ctx.sendToSessionOthers; var opts = ctx.opts; var usersModule = ctx.usersModule; + var projectAccess = ctx.projectAccess; var userPresence = ctx.userPresence; var pushModule = ctx.pushModule; var getSessionForWs = ctx.getSessionForWs; @@ -205,7 +208,7 @@ function attachSessions(ctx) { var sessionOpts = {}; if (ws._clagenticUser) sessionOpts.ownerId = ws._clagenticUser.id; if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility; - if (msg.vendor) sessionOpts.vendor = msg.vendor; + if (isKnownVendor(msg.vendor)) sessionOpts.vendor = msg.vendor; if (msg.agentName && typeof msg.agentName === "string") { sessionOpts.agentName = msg.agentName; try { agentsFavorites.touchRecent({ name: msg.agentName }); } @@ -410,6 +413,14 @@ function attachSessions(ctx) { if (msg.type === "bulk_delete_sessions") { if (!Array.isArray(msg.sessionIds) || msg.sessionIds.length === 0) return true; + // Same permission as delete_session; the per-session access check below is not a substitute. + if (ws._clagenticUser) { + var bulkPerms = usersModule.getEffectivePermissions(ws._clagenticUser, osUsers); + if (!bulkPerms.sessionDelete) { + sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" }); + return true; + } + } var deletableIds = []; for (var di = 0; di < msg.sessionIds.length; di++) { var bulkId = msg.sessionIds[di]; @@ -568,7 +579,7 @@ function attachSessions(ctx) { var switchTargetSess = sm.sessions.get(msg.id); if (switchTargetSess && sm.currentModel) { var targetVendor = switchTargetSess.vendor || sm.defaultVendor || null; - var tvModels = (targetVendor && sm.modelsByVendor && sm.modelsByVendor[targetVendor]) || []; + var tvModels = (targetVendor && sm.modelsByVendor && ownValue(sm.modelsByVendor, targetVendor)) || []; var found = false; var _curLc = sm.currentModel.toLowerCase(); for (var tvi = 0; tvi < tvModels.length; tvi++) { @@ -619,14 +630,15 @@ function attachSessions(ctx) { return true; } } - if (msg.id && sm.sessions.has(msg.id)) { + // The permission to delete is not access to this particular session. + if (msg.id && sm.sessions.has(msg.id) && projectAccess.canReadSession(ws._clagenticUser, sm.sessions.get(msg.id), projectAccess.authOf(ws))) { sm.deleteSession(msg.id, ws); } return true; } if (msg.type === "rename_session") { - if (msg.id && sm.sessions.has(msg.id) && msg.title) { + if (msg.id && sm.sessions.has(msg.id) && msg.title && projectAccess.canReadSession(ws._clagenticUser, sm.sessions.get(msg.id), projectAccess.authOf(ws))) { var s = sm.sessions.get(msg.id); s.title = String(msg.title).substring(0, 100); s.titleManuallySet = true; @@ -643,17 +655,25 @@ function attachSessions(ctx) { } if (msg.type === "search_sessions") { - var results = sm.searchSessions(msg.query || ""); - sendTo(ws, { type: "search_results", query: msg.query || "", results: results }); + // Sessions the caller cannot read are skipped before anything is read from + // them, so neither their titles nor whether their content matches is revealed. + var searchQuery = typeof msg.query === "string" ? msg.query : ""; + var results = sm.searchSessions(searchQuery, function (candidate) { + return projectAccess.canReadSession(ws._clagenticUser, candidate, projectAccess.authOf(ws)); + }); + sendTo(ws, { type: "search_results", query: searchQuery, results: results }); return true; } if (msg.type === "search_session_content") { var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws); if (!targetSession) return true; - var contentResults = sm.searchSessionContent(targetSession.localId, msg.query || ""); - var searchResp = { type: "search_content_results", query: msg.query || "", sessionId: targetSession.localId, hits: contentResults.hits, total: contentResults.total }; - if (msg.source) searchResp.source = msg.source; + if (!projectAccess.canReadSession(ws._clagenticUser, targetSession, projectAccess.authOf(ws))) return true; + // Same coercion as search_sessions: only a string query is searched or echoed. + var contentQuery = typeof msg.query === "string" ? msg.query : ""; + var contentResults = sm.searchSessionContent(targetSession.localId, contentQuery); + var searchResp = { type: "search_content_results", query: contentQuery, sessionId: targetSession.localId, hits: contentResults.hits, total: contentResults.total }; + if (typeof msg.source === "string" && msg.source) searchResp.source = msg.source; sendTo(ws, searchResp); return true; } @@ -838,6 +858,12 @@ function attachSessions(ctx) { } if (msg.type === "set_vendor" && msg.vendor) { + // A vendor the daemon cannot build is no vendor: nothing is bound, + // looked up or echoed for it. + if (!isKnownVendor(msg.vendor)) { + sendTo(ws, { type: "error", text: "Unknown vendor" }); + return true; + } var vendorSession = getSessionForWs(ws); if (vendorSession) { // Refuse to rebind vendor on a session that is already bound to a @@ -876,7 +902,7 @@ function attachSessions(ctx) { } } if (msg.vendor) { - var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[msg.vendor]) || []; + var vendorModels = (sm.modelsByVendor && ownValue(sm.modelsByVendor, msg.vendor)) || []; sendTo(ws, { type: "model_info", model: "", @@ -1487,7 +1513,8 @@ function attachSessions(ctx) { var moveResult = moveScheduleToProject(msg.recordId, msg.fromSlug, msg.toSlug); if (moveResult.ok) { // Re-broadcast updated records to this project's clients - send({ type: "loop_registry_updated", records: getHubSchedules() }); + if (typeof ctx.broadcastHubSchedules === "function") ctx.broadcastHubSchedules(); + else send({ type: "loop_registry_updated", records: getHubSchedules(null) }); } sendTo(ws, { type: "schedule_move_result", ok: moveResult.ok, error: moveResult.error }); return true; diff --git a/lib/project-user-message.js b/lib/project-user-message.js index 72fb3ece..6b749bee 100644 --- a/lib/project-user-message.js +++ b/lib/project-user-message.js @@ -1,6 +1,8 @@ var path = require("path"); var fs = require("fs"); var { promptsFor } = require("./prompt-registry"); +var { putOwn } = require("./prompt-kinds/codecs"); +var { isKnownVendor } = require("./yoke"); /** * Attach user-message handler and remaining small handlers @@ -111,6 +113,8 @@ function attachUserMessage(ctx) { var _loop = ctx._loop; var browserState = ctx.browserState; + // Without a binding to the sockets commands were sent to, nothing may answer. + var mayAnswerExtensionCommand = ctx.mayAnswerExtensionCommand || function () { return false; }; var sendExtensionCommandAny = ctx.sendExtensionCommandAny; var requestTabContext = ctx.requestTabContext; @@ -275,15 +279,24 @@ function attachUserMessage(ctx) { if (msg.type === "browser_tab_list") { browserState._extensionWs = ws; // Track which client has the extension if (msg.extensionId) browserState._extensionId = msg.extensionId; - var tabs = msg.tabs || []; - browserState._browserTabList = {}; + var tabs = Array.isArray(msg.tabs) ? msg.tabs : []; + // Updated in place: the list is shared with the HTTP bridge, and its keys + // are tab ids from the client, so it is a prototype-less map filled only + // from entries that carry a numeric id. + var tabList = browserState._browserTabList; + Object.keys(tabList).forEach(function (staleId) { delete tabList[staleId]; }); for (var bti = 0; bti < tabs.length; bti++) { - browserState._browserTabList[tabs[bti].id] = tabs[bti]; + var tab = tabs[bti]; + if (!tab || typeof tab !== "object" || typeof tab.id !== "number" || !isFinite(tab.id)) continue; + putOwn(tabList, String(tab.id), tab); } return true; } + // Only the socket a command was sent to can answer it: any other + // authenticated client's extension_result is ignored. if (msg.type === "extension_result") { + if (!mayAnswerExtensionCommand(msg.requestId, ws)) return true; promptsFor(sm).respond(msg.requestId, { result: msg.result }, { kinds: ["extension"] }); return true; } @@ -380,7 +393,7 @@ function attachUserMessage(ctx) { session.humanOriginated = true; // Bind vendor to session on first message (if not already set) - if (!session.vendor && msg.vendor) { + if (!session.vendor && isKnownVendor(msg.vendor)) { session.vendor = msg.vendor; sm.saveSessionFile(session); sm.broadcastSessionList(); diff --git a/lib/project.js b/lib/project.js index ee3f885e..dcc7724d 100644 --- a/lib/project.js +++ b/lib/project.js @@ -20,6 +20,12 @@ var { attachHTTP } = require("./project-http"); var { attachImage } = require("./project-image"); var { attachKnowledge } = require("./project-knowledge"); var { attachFilesystem } = require("./project-filesystem"); +var { safePath, safeClaudePath, createFileAccess } = require("./project-file-scope"); +var { createMessageGate } = require("./project-message-gate"); +var { createProjectAccess } = require("./project-access"); +var { createExtensionCommands } = require("./extension-commands"); +var { ownValue, putOwn } = require("./prompt-kinds/codecs"); +var { isKnownVendor } = require("./yoke"); var { attachSessions } = require("./project-sessions"); var { attachUserMessage } = require("./project-user-message"); var { attachConnection } = require("./project-connection"); @@ -98,46 +104,6 @@ var BINARY_EXTS = new Set([ ]); var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]); var FS_MAX_SIZE = 512 * 1024; -function safePath(base, requested) { - var resolved = path.resolve(base, requested); - if (resolved !== base && !resolved.startsWith(base + path.sep)) return null; - try { - var real = fs.realpathSync(resolved); - if (real !== base && !real.startsWith(base + path.sep)) return null; - return real; - } catch (e) { - return null; - } -} - -// Resolve an absolute path without requiring it to be within cwd. -// Used as fallback in OS user mode where ACL enforces access at the OS level. -function safeAbsPath(requested) { - if (!requested) return null; - var resolved = path.resolve(requested); - try { - return fs.realpathSync(resolved); - } catch (e) { - return null; - } -} - -// Resolve an absolute path within the Claude Code data directory (~/.claude/). -// Used as read-only fallback so plan files and other Claude-owned paths that live -// outside the project cwd (e.g. ~/.claude/plans/) can be previewed in the file viewer. -function safeClaudePath(requested) { - if (!requested) return null; - var claudeDir = path.join(require("./config").REAL_HOME, ".claude"); - var resolved = path.resolve(requested); - if (resolved !== claudeDir && !resolved.startsWith(claudeDir + path.sep)) return null; - try { - var real = fs.realpathSync(resolved); - if (real !== claudeDir && !real.startsWith(claudeDir + path.sep)) return null; - return real; - } catch (e) { - return null; - } -} /** * Create a project context — per-project state and handlers. @@ -183,6 +149,14 @@ function createProjectContext(opts) { var updateChannel = opts.updateChannel || "stable"; var onSetUpdateChannel = opts.onSetUpdateChannel || null; var osUsers = opts.osUsers || false; + // server.js wires the daemon's resolver for every real project. A context + // built without one (unit harnesses) has no user system behind it, so it is + // single-user: a connection with no user record is its implicit owner. + var projectAccess = opts.projectAccess || createProjectAccess({ + users: usersModule, + onGetProjectAccess: null, + isMultiUser: function () { return false; }, + }); var projectOwnerId = opts.projectOwnerId || null; var worktreeMeta = opts.worktreeMeta || null; // { parentSlug, branch, accessible } var onCreateWorktree = opts.onCreateWorktree || null; @@ -274,23 +248,22 @@ function createProjectContext(opts) { // --- Browser extension state (shared mutable object) --- var _extToken = crypto.randomUUID(); // Auth token for MCP server bridge + // The tab list is keyed by tab ids the extension reports, so it has no + // prototype to inherit from; it is only ever updated in place so every + // holder of the reference sees the current tabs. var browserState = { - _browserTabList: {}, + _browserTabList: Object.create(null), _extensionWs: null, }; // The extension answers with extension_result (project-user-message.js); - // an unanswered command resolves to null after its timeout. - function sendExtensionCommand(ws, command, args, timeout) { - var opened = promptsFor(sm).open(null, "extension", { command: command, args: args }, { timeoutMs: timeout }); - sendTo(ws, { - type: "extension_command", - command: command, - args: args, - requestId: opened.requestId - }); - return opened.answer; - } + // an unanswered command resolves to null after its timeout. Only the socket a + // command was sent to may answer it (lib/extension-commands.js). + var extensionCommands = createExtensionCommands({ + getSessionManager: function () { return sm; }, + sendTo: sendTo, + }); + var sendExtensionCommand = extensionCommands.send; // Send extension command via the tracked extension client (for MCP bridge) function sendExtensionCommandAny(command, args, timeout) { @@ -399,6 +372,15 @@ function createProjectContext(opts) { } } + // getHubSchedules(userId) returns only the schedules of projects that user + // may see, so a schedule list is never sent to a client unfiltered. + function broadcastHubSchedules() { + for (var hws of clients) { + if (hws.readyState !== 1) continue; + sendTo(hws, { type: "loop_registry_updated", records: getHubSchedules(hws._clagenticUser ? hws._clagenticUser.id : null, projectAccess.authOf(hws)) }); + } + } + // --- Knowledge engine (delegated to project-knowledge.js) --- var _knowledge = attachKnowledge({ cwd: cwd, @@ -407,19 +389,30 @@ function createProjectContext(opts) { }); // --- File/directory watcher engine (delegated to project-file-watch.js) --- + // One file policy for the WS file messages and the watchers. + var fileAccess = createFileAccess({ + osUsers: osUsers, + usersModule: usersModule, + projectAccess: projectAccess, + fsAsUser: fsAsUser, + getOsUserInfoForWs: getOsUserInfoForWs, + binaryExts: BINARY_EXTS, + maxSize: FS_MAX_SIZE, + }); var _fileWatch = attachFileWatch({ cwd: cwd, - send: send, + sendTo: sendTo, safePath: safePath, - BINARY_EXTS: BINARY_EXTS, - FS_MAX_SIZE: FS_MAX_SIZE, + authorize: fileAccess.authorize, + readFile: fileAccess.readFile, + listDir: fileAccess.listDir, IGNORED_DIRS: IGNORED_DIRS, }); var startFileWatch = _fileWatch.startFileWatch; var stopFileWatch = _fileWatch.stopFileWatch; var startDirWatch = _fileWatch.startDirWatch; - var stopDirWatch = _fileWatch.stopDirWatch; var stopAllDirWatches = _fileWatch.stopAllDirWatches; + var stopAllWatches = _fileWatch.stopAll; // --- Session manager --- var sm = createSessionManager({ @@ -632,6 +625,7 @@ function createProjectContext(opts) { pushModule: pushModule, notificationsModule: _notifications, getHubSchedules: getHubSchedules, + broadcastHubSchedules: broadcastHubSchedules, getAllProjectSessions: getAllProjectSessions, getSessionUnread: getSessionUnread, getStatus: getStatus, @@ -737,15 +731,28 @@ function createProjectContext(opts) { } } + // opts.canAccessProject(userId, slug, proof) is wired by server.js for every real + // project; a context built without it (unit harnesses) has no access policy + // to apply to its own slug but still refuses to forward to another project. + var _messageGate = createMessageGate({ + slug: slug, + canAccess: typeof opts.canAccessProject === "function" ? opts.canAccessProject : null, + getProject: opts.getProject, + }); + function handleMessage(ws, msg) { + // --- Access gate: every message is authorized before any handler runs --- + var gate = _messageGate.authorize(ws, msg); + if (!gate.allowed) { + if (gate.error) sendTo(ws, { type: "error", text: gate.error }); + return; + } // --- Cross-project routing (e.g. permission_response from notification banner) --- - if (msg.targetSlug && msg.targetSlug !== slug && opts.getProject) { - var targetCtx = opts.getProject(msg.targetSlug); - if (targetCtx) { - targetCtx.handleMessage(ws, msg); - return; - } + if (gate.target) { + gate.target.handleMessage(ws, gate.message); + return; } + msg = gate.message; // --- Team activity messages (delegated to project-team.js) --- if (_team.handleClientMsg(ws, msg)) return; @@ -766,19 +773,22 @@ function createProjectContext(opts) { // --- Vendor model switching --- if (msg.type === "get_vendor_models") { + // The vendor is a client-supplied name: only a known vendor is ever + // used as a key, and every lookup reads own properties only. + var requestedVendor = isKnownVendor(msg.vendor) ? msg.vendor : null; (async function() { - if (msg.vendor) { + if (requestedVendor) { try { - var vendorAdapter = adapters[msg.vendor] || null; + var vendorAdapter = ownValue(adapters, requestedVendor) || null; if (!vendorAdapter) { - vendorAdapter = await yoke.lazyCreateAdapter(adapters, msg.vendor, { + vendorAdapter = await yoke.lazyCreateAdapter(adapters, requestedVendor, { cwd: cwd, clayPort: serverPort, clayTls: serverTls, clayAuthToken: serverAuthToken, slug: slug, }); - } else if ((!sm.modelsByVendor || !sm.modelsByVendor[msg.vendor]) && typeof vendorAdapter.init === "function") { + } else if ((!sm.modelsByVendor || !ownValue(sm.modelsByVendor, requestedVendor)) && typeof vendorAdapter.init === "function") { await vendorAdapter.init({ cwd: cwd, clayPort: serverPort, @@ -789,16 +799,16 @@ function createProjectContext(opts) { } if (vendorAdapter) { sm.availableVendors = Object.keys(adapters); - sm.modelsByVendor = sm.modelsByVendor || {}; - if (!sm.modelsByVendor[msg.vendor] && typeof vendorAdapter.supportedModels === "function") { - sm.modelsByVendor[msg.vendor] = await vendorAdapter.supportedModels(); + sm.modelsByVendor = sm.modelsByVendor || Object.create(null); + if (!ownValue(sm.modelsByVendor, requestedVendor) && typeof vendorAdapter.supportedModels === "function") { + putOwn(sm.modelsByVendor, requestedVendor, await vendorAdapter.supportedModels()); } } } catch (e) { - console.error("[project] get_vendor_models lazy init failed for " + msg.vendor + ":", e.message || e); + console.error("[project] get_vendor_models lazy init failed for " + requestedVendor + ":", e.message || e); } } - var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[msg.vendor]) || []; + var vendorModels = (sm.modelsByVendor && ownValue(sm.modelsByVendor, requestedVendor)) || []; var firstModel = vendorModels[0] || ""; // model value can be string or {value, displayName} object var defaultModel = typeof firstModel === "string" ? firstModel : (firstModel.value || ""); @@ -814,7 +824,7 @@ function createProjectContext(opts) { } } } - sendTo(ws, { type: "model_info", model: modelToSend, models: vendorModels, vendor: msg.vendor, availableVendors: sm.availableVendors || [], installedVendors: sm.installedVendors || [] }); + sendTo(ws, { type: "model_info", model: modelToSend, models: vendorModels, vendor: typeof msg.vendor === "string" ? msg.vendor : "", availableVendors: sm.availableVendors || [], installedVendors: sm.installedVendors || [] }); })(); return; } @@ -923,6 +933,7 @@ function createProjectContext(opts) { sendToSessionOthers: sendToSessionOthers, opts: opts, usersModule: usersModule, + projectAccess: projectAccess, userPresence: userPresence, pushModule: pushModule, getSessionForWs: getSessionForWs, @@ -939,6 +950,7 @@ function createProjectContext(opts) { moveScheduleToProject: moveScheduleToProject, moveAllSchedulesToProject: moveAllSchedulesToProject, getHubSchedules: getHubSchedules, + broadcastHubSchedules: broadcastHubSchedules, fetchVersion: fetchVersion, isNewer: isNewer, scheduleMessage: scheduleMessage, @@ -984,6 +996,7 @@ function createProjectContext(opts) { onProcessingChanged: onProcessingChanged, _loop: _loop, browserState: browserState, + mayAnswerExtensionCommand: extensionCommands.mayAnswer, sendExtensionCommandAny: sendExtensionCommandAny, requestTabContext: requestTabContext, scheduleMessage: scheduleMessage, @@ -1004,21 +1017,18 @@ function createProjectContext(opts) { sm: sm, send: send, sendTo: sendTo, - safePath: safePath, - safeAbsPath: safeAbsPath, - safeClaudePath: safeClaudePath, + fileAccess: fileAccess, getOsUserInfoForWs: getOsUserInfoForWs, startFileWatch: startFileWatch, stopFileWatch: stopFileWatch, startDirWatch: startDirWatch, usersModule: usersModule, + projectAccess: projectAccess, fsAsUser: fsAsUser, validateEnvString: validateEnvString, opts: opts, IGNORED_DIRS: IGNORED_DIRS, - BINARY_EXTS: BINARY_EXTS, IMAGE_EXTS: IMAGE_EXTS, - FS_MAX_SIZE: FS_MAX_SIZE, }); // --- MCP bridge handler for Codex (Track 2) --- @@ -1124,8 +1134,7 @@ function createProjectContext(opts) { imagesDir: imagesDir, osUsers: osUsers, pushModule: pushModule, - safePath: safePath, - safeAbsPath: safeAbsPath, + projectAccess: projectAccess, getOsUserInfoForReq: getOsUserInfoForReq, sendExtensionCommandAny: sendExtensionCommandAny, _extToken: _extToken, @@ -1190,8 +1199,7 @@ function createProjectContext(opts) { // --- Destroy --- function destroy() { _loop.stopTimer(); - stopFileWatch(); - stopAllDirWatches(); + stopAllWatches(); // Abort all active sessions and clean up mention sessions sm.sessions.forEach(function (session) { session.destroying = true; @@ -1304,7 +1312,11 @@ function createProjectContext(opts) { function setTitle(newTitle) { title = newTitle || null; - send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, osUsers: osUsers, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList(), projectOwnerId: projectOwnerId }); + // Each client is sent the project list it is allowed to see. + for (var ws of clients) { + var visibleProjects = getProjectList(ws._clagenticUser ? ws._clagenticUser.id : null, projectAccess.authOf(ws)); + sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, osUsers: osUsers, lanHost: lanHost, projectCount: visibleProjects.length, projects: visibleProjects, projectOwnerId: projectOwnerId }); + } } function setIcon(newIcon) { diff --git a/lib/prompt-registry.js b/lib/prompt-registry.js index 1630f42e..1ba9c09a 100644 --- a/lib/prompt-registry.js +++ b/lib/prompt-registry.js @@ -132,17 +132,17 @@ function createPromptRegistry(deps) { var resolutionEffects = null; function sessionMap(session, store) { - if (!session[store]) session[store] = {}; + if (!session[store]) session[store] = Object.create(null); return session[store]; } function toolOwners(session) { - if (!session.subagentToolOwners) session.subagentToolOwners = {}; + if (!session.subagentToolOwners) session.subagentToolOwners = Object.create(null); return session.subagentToolOwners; } function trackedTasks(session) { - if (!session.activeTaskToolIds) session.activeTaskToolIds = {}; + if (!session.activeTaskToolIds) session.activeTaskToolIds = Object.create(null); return session.activeTaskToolIds; } @@ -472,8 +472,8 @@ function createPromptRegistry(deps) { /** End every prompt of a session (rewind, context clear, deletion). */ function cancelSession(session, reason, opts) { cancelWhere(session, reason, function () { return true; }, opts); - session.activeTaskToolIds = {}; - session.subagentToolOwners = {}; + session.activeTaskToolIds = Object.create(null); + session.subagentToolOwners = Object.create(null); } /** @@ -522,7 +522,7 @@ function createPromptRegistry(deps) { /** Ids of every tracked Task. */ function trackedTaskIds(session) { - var out = {}; + var out = Object.create(null); Object.keys(trackedTasks(session)).forEach(function (taskId) { putOwn(out, taskId, true); }); return out; } @@ -534,7 +534,7 @@ function createPromptRegistry(deps) { // Flushed immediately so the grant survives a daemon restart or a resume // that rebuilds the session from disk. function grant(session, toolName, input) { - if (!session.allowedTools) session.allowedTools = {}; + if (!session.allowedTools) session.allowedTools = Object.create(null); putOwn(session.allowedTools, utils.permissionGrantKey(toolName, input), true); saveSessionFile(session); } @@ -635,7 +635,7 @@ function createPromptRegistry(deps) { */ function promptsFor(sm) { if (!sm.prompts) { - if (!sm.permissionRequestIndex) sm.permissionRequestIndex = {}; + if (!sm.permissionRequestIndex) sm.permissionRequestIndex = Object.create(null); sm.prompts = createPromptRegistry({ index: sm.permissionRequestIndex, getSession: function (localId) { return sm.sessions ? sm.sessions.get(localId) : null; }, diff --git a/lib/public/modules/app-cursors.js b/lib/public/modules/app-cursors.js index ffcb9448..c18b8d4f 100644 --- a/lib/public/modules/app-cursors.js +++ b/lib/public/modules/app-cursors.js @@ -10,7 +10,9 @@ import { getSessionPresenceUsers } from './sidebar-sessions.js'; // --- Module-owned state --- var cursorSharingEnabled = localStorage.getItem("cursorSharing") !== "off"; -var remoteCursors = {}; // userId -> { el, indicator, timer, lastY, active } +// The three tables below are keyed by ids a server message carries, so they +// have no prototype to answer for "constructor" or "__proto__". +var remoteCursors = Object.create(null); // userId -> { el, indicator, timer, lastY, active } var cursorThrottleTimer = null; var CURSOR_THROTTLE_MS = 30; var CURSOR_HIDE_TIMEOUT = 5000; @@ -20,10 +22,10 @@ var cursorColors = [ "#0ACF83", "#FF6D00", "#E84393", "#6C5CE7", "#00B894", "#FDCB6E", "#E17055", "#74B9FF", ]; -var userColorMap = {}; +var userColorMap = Object.create(null); var nextColorIdx = 0; -var remoteSelections = {}; // userId -> { els: [], timer } +var remoteSelections = Object.create(null); // userId -> { els: [], timer } var selectionThrottleTimer = null; var lastSelectionKey = ""; @@ -388,12 +390,12 @@ export function clearRemoteCursors() { if (entry.el.parentNode) entry.el.parentNode.removeChild(entry.el); if (entry.indicator && entry.indicator.parentNode) entry.indicator.parentNode.removeChild(entry.indicator); } - remoteCursors = {}; + remoteCursors = Object.create(null); for (var uid2 in remoteSelections) { clearRemoteSelection(uid2); if (remoteSelections[uid2].timer) clearTimeout(remoteSelections[uid2].timer); } - remoteSelections = {}; + remoteSelections = Object.create(null); } export function initCursors() { diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index cf23bf5e..154f6f8c 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -85,7 +85,7 @@ var connectOverlay = document.getElementById("connect-overlay"); // halt). Do not unconditionally reassign: that would drop any // registrations a circular caller already made before this module's own // body reached this line. -var handlers = handlers || {}; +var handlers = handlers || Object.create(null); /** * Register one or more message-type handlers. Called by this module's own @@ -101,20 +101,20 @@ var handlers = handlers || {}; * here, and this file imports back from them for their non-registry * exports). Depending on which module app.js's graph reaches first, ESM's * cycle-breaking semantics can run a caller's top-level registerHandlers() - * call before this file's own `var handlers = {}` line has executed — + * call before this file's own `var handlers` line has executed — * registerHandlers itself is always safely callable (function declarations * are hoisted at instantiation), but the object it wrote into on the module * scope was not yet assigned, throwing "Cannot read properties of * undefined" and halting the whole app.js boot before connect() runs. * Lazily initializing here (independent of module-body execution order) * makes registration order-safe: the first call — whichever module makes - * it — creates the registry, and the `var handlers = {}` below becomes a + * it — creates the registry, and the `var handlers` line below becomes a * no-op once this module's own body eventually runs. * * @param {Object} map - type -> handler */ export function registerHandlers(map) { - if (!handlers) handlers = {}; + if (!handlers) handlers = Object.create(null); Object.keys(map).forEach(function (type) { if (!handlers[type]) handlers[type] = []; handlers[type].push(map[type]); diff --git a/lib/public/modules/filebrowser.js b/lib/public/modules/filebrowser.js index 72f9bee2..fd29eff5 100644 --- a/lib/public/modules/filebrowser.js +++ b/lib/public/modules/filebrowser.js @@ -15,7 +15,7 @@ import { registerHandlers } from './app-messages.js'; var ctx; var showDropHint = function () {}; -var treeData = {}; // path -> { loaded, children } +var treeData = Object.create(null); // path -> { loaded, children } var currentContent = null; // last read file content for copy var currentFilePath = null; // path of the currently viewed file var isRendered = false; // markdown render toggle state @@ -26,9 +26,9 @@ var currentHistoryEntries = []; var selectedEntries = []; // up to 2 selected for compare var compareMode = false; var inlineDiffActive = false; -var gitDiffCache = {}; // hash -> diff text +var gitDiffCache = Object.create(null); // hash -> diff text var pendingGitDiff = null; // callback for pending git diff -var fileAtCache = {}; // hash -> file content +var fileAtCache = Object.create(null); // hash -> file content var pendingFileAt = null; // { hash, cb } for the in-flight fs_file_at request, or null export function initFileBrowser(_ctx) { @@ -419,7 +419,7 @@ export function resetFileBrowser() { // Close viewer closeFileViewer(); // Clear all cached state - treeData = {}; + treeData = Object.create(null); currentContent = null; currentFilePath = null; isRendered = false; @@ -430,9 +430,9 @@ export function resetFileBrowser() { selectedEntries = []; compareMode = false; inlineDiffActive = false; - gitDiffCache = {}; + gitDiffCache = Object.create(null); pendingGitDiff = null; - fileAtCache = {}; + fileAtCache = Object.create(null); pendingFileAt = null; pendingReadPath = null; pendingOpenMode = null; @@ -563,13 +563,15 @@ export function handleFsSearch(msg) { } // Build a tree structure from flat search results - var tree = {}; + // Keyed by file and directory names, so a file called "constructor" or + // "__proto__" must not find an inherited entry. + var tree = Object.create(null); for (var i = 0; i < entries.length; i++) { var entry = entries[i]; var parts = entry.path.split("/"); var node = tree; for (var j = 0; j < parts.length; j++) { - if (!node[parts[j]]) node[parts[j]] = {}; + if (!node[parts[j]]) node[parts[j]] = Object.create(null); if (j === parts.length - 1) { node[parts[j]]._entry = entry; } else { @@ -713,7 +715,7 @@ export function handleFsList(msg) { // Root level if (dirPath === ".") { // Preserve expanded state across re-render - var expandedSet = {}; + var expandedSet = Object.create(null); var expandedEls = ctx.fileTreeEl.querySelectorAll(".file-tree-item.expanded"); for (var ei = 0; ei < expandedEls.length; ei++) { var sib = expandedEls[ei].nextElementSibling; @@ -747,7 +749,7 @@ export function handleDirChanged(msg) { } // Collect expanded directories before re-render - var expandedSet = {}; + var expandedSet = Object.create(null); var expandedEls = ctx.fileTreeEl.querySelectorAll(".file-tree-item.expanded"); for (var i = 0; i < expandedEls.length; i++) { var sib = expandedEls[i].nextElementSibling; @@ -987,8 +989,8 @@ function showFileContent(msg) { compareMode = false; selectedEntries = []; currentHistoryEntries = []; - gitDiffCache = {}; - fileAtCache = {}; + gitDiffCache = Object.create(null); + fileAtCache = Object.create(null); var historyBtn2 = document.getElementById("file-viewer-history"); historyBtn2.classList.add("hidden"); historyBtn2.classList.remove("active"); @@ -1004,8 +1006,8 @@ function showFileContent(msg) { compareMode = false; selectedEntries = []; currentHistoryEntries = []; - gitDiffCache = {}; - fileAtCache = {}; + gitDiffCache = Object.create(null); + fileAtCache = Object.create(null); var historyBtn = document.getElementById("file-viewer-history"); historyBtn.classList.add("hidden"); historyBtn.classList.remove("active"); @@ -1728,7 +1730,7 @@ function editCodeSummary(oldStr, newStr) { // Find the first meaningful added or changed line to use as a subtitle var oldLines = oldStr ? oldStr.split("\n") : []; var newLines = newStr ? newStr.split("\n") : []; - var oldSet = {}; + var oldSet = Object.create(null); for (var i = 0; i < oldLines.length; i++) { var trimmed = oldLines[i].trim(); if (trimmed) oldSet[trimmed] = true; @@ -1742,7 +1744,7 @@ function editCodeSummary(oldStr, newStr) { } } // Fallback: find first removed line - var newSet = {}; + var newSet = Object.create(null); for (var k = 0; k < newLines.length; k++) { var t = newLines[k].trim(); if (t) newSet[t] = true; diff --git a/lib/public/modules/sticky-notes.js b/lib/public/modules/sticky-notes.js index 43a024c2..def03d8a 100644 --- a/lib/public/modules/sticky-notes.js +++ b/lib/public/modules/sticky-notes.js @@ -6,8 +6,9 @@ var ctx; var notes = new Map(); // id -> { data, el } var notesVisible = false; var archiveOpen = false; -var updateTimers = {}; -var textTimers = {}; +// Keyed by note ids from the server: no prototype to answer for them. +var updateTimers = Object.create(null); +var textTimers = Object.create(null); var colorPickerEl = null; var formatToolbarEl = null; diff --git a/lib/public/modules/team-panel.js b/lib/public/modules/team-panel.js index 69e5ed8a..66bcb5b2 100644 --- a/lib/public/modules/team-panel.js +++ b/lib/public/modules/team-panel.js @@ -223,8 +223,9 @@ export function initTeamPanel() { export function handleTeamState(msg) { currentTeam = msg.team || null; if (currentTeam) { - currentTeam._memberStatuses = currentTeam._memberStatuses || {}; - currentTeam._memberActivities = currentTeam._memberActivities || {}; + // Keyed by agent ids from the server: no prototype to answer for them. + currentTeam._memberStatuses = currentTeam._memberStatuses || Object.create(null); + currentTeam._memberActivities = currentTeam._memberActivities || Object.create(null); } teamMessages = msg.messages || []; @@ -248,8 +249,8 @@ export function handleTeamMemberUpdate(msg) { // Single member status/activity update if (msg.agentId) { - currentTeam._memberStatuses = currentTeam._memberStatuses || {}; - currentTeam._memberActivities = currentTeam._memberActivities || {}; + currentTeam._memberStatuses = currentTeam._memberStatuses || Object.create(null); + currentTeam._memberActivities = currentTeam._memberActivities || Object.create(null); if (msg.status) { currentTeam._memberStatuses[msg.agentId] = msg.status; } diff --git a/lib/server-hub-sessions.js b/lib/server-hub-sessions.js index f415ba81..eeb7a911 100644 --- a/lib/server-hub-sessions.js +++ b/lib/server-hub-sessions.js @@ -10,11 +10,9 @@ // // userId: REQUIRED to filter results to what the viewing user may actually // see. Fails CLOSED: no userId (or no onGetProjectAccess) means no sessions -// are returned. There is no legitimate no-auth caller of this function -- -// the WS upgrade handler in lib/server.js rejects any connection without an -// authenticated user with 401 before ws._clagenticUser can ever be set, including -// in single-user/PIN mode (still a real authenticated user, just one -// account) -- so failing closed here never breaks a real deployment mode. +// are returned, unless the caller is the implicit single-user owner +// (implicitOwner, decided by the daemon's mode in lib/project-access.js), +// who sees every project and session. // // deps: // projects Map (or anything with .forEach(fn(ctx, slug))) @@ -23,6 +21,7 @@ // callerSlug the connecting project's own slug (for includeSelf skip) // includeSelf when true, callerSlug's own sessions are included too // userId the viewing user's id, or null/undefined to fail closed +// implicitOwner true for the single-user owner, who has no user record function computeAllProjectSessions(deps) { var projects = deps.projects; var users = deps.users; @@ -30,9 +29,10 @@ function computeAllProjectSessions(deps) { var callerSlug = deps.callerSlug; var includeSelf = deps.includeSelf; var userId = deps.userId; + var implicitOwner = deps.implicitOwner === true; var allSessions = []; - if (!userId || !onGetProjectAccess) return allSessions; + if (!implicitOwner && (!userId || !onGetProjectAccess)) return allSessions; projects.forEach(function (pCtx, pSlug) { if (!includeSelf && pSlug === callerSlug) return; // skip self unless asked @@ -45,13 +45,16 @@ function computeAllProjectSessions(deps) { // the whole project up front if the user cannot access it at all -- // avoids an O(projects * sessions) repeated-resolution pattern for a // value that does not vary per session. - var access = onGetProjectAccess(pSlug); - if (!access || access.error) return; - if (!users.canAccessProject(userId, access)) return; + var access = null; + if (!implicitOwner) { + access = onGetProjectAccess(pSlug); + if (!access || access.error) return; + if (!users.canAccessProject(userId, access)) return; + } var projectTitle = status.title || status.project || pSlug; var projectIcon = status.icon || null; pSm.sessions.forEach(function (s) { - if (!s.hidden && users.canAccessSession(userId, s, access)) { + if (!s.hidden && (implicitOwner || users.canAccessSession(userId, s, access))) { // Push a shallow copy annotated with project metadata rather than // mutating the live session object with transient UI fields. allSessions.push(Object.assign({}, s, { diff --git a/lib/server-palette.js b/lib/server-palette.js index 193899db..b602a054 100644 --- a/lib/server-palette.js +++ b/lib/server-palette.js @@ -4,13 +4,32 @@ function attachPalette(ctx) { var users = ctx.users; var projects = ctx.projects; var getMultiUserFromReq = ctx.getMultiUserFromReq; - var onGetProjectAccess = ctx.onGetProjectAccess; + var isRequestAuthed = ctx.isRequestAuthed; + var projectAccess = ctx.projectAccess; + + // What the single-user owner is shown: every project, as a public one. + var OWNER_PROJECT_ACCESS = { visibility: "public" }; + + // The project's access record when the principal may use that project, else + // null. A failed lookup is a refusal, never an allow. + function accessibleProject(principal, slug) { + if (principal.implicit) return OWNER_PROJECT_ACCESS; + var access = projectAccess.resolve(slug); + return access && users.canAccessProject(principal.id, access) ? access : null; + } + + function canSeeSession(principal, session, access) { + return principal.implicit || users.canAccessSession(principal.id, session, access); + } function handleRequest(req, res, fullUrl) { if (req.method !== "GET" || fullUrl !== "/api/palette/search") return false; - var paletteUser = getMultiUserFromReq(req); - if (!paletteUser) { + // Authentication first: appHandler has no login check ahead of this + // route, and "no user" is the implicit owner only for a request that + // proved it logged in. Without that proof there is no principal in any mode. + var principal = projectAccess.principalFor(getMultiUserFromReq(req), { authenticated: isRequestAuthed(req) === true }); + if (!principal) { res.writeHead(401, { "Content-Type": "application/json" }); res.end('{"error":"unauthorized"}'); return true; @@ -24,16 +43,11 @@ function attachPalette(ctx) { projects.forEach(function (pCtx, pSlug) { var status = pCtx.getStatus(); if (status.isWorktree) return; - if (paletteUser && onGetProjectAccess) { - var pAccess = onGetProjectAccess(pSlug); - if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return; - } + var pAccess = accessibleProject(principal, pSlug); + if (!pAccess) return; pCtx.sm.sessions.forEach(function (session) { if (session.hidden) return; - if (paletteUser) { - var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null; - if (!users.canAccessSession(paletteUser.id, session, sAccess)) return; - } + if (!canSeeSession(principal, session, pAccess)) return; var pItem = { projectSlug: pSlug, projectTitle: status.title || status.project, @@ -64,17 +78,12 @@ function attachPalette(ctx) { projects.forEach(function (pCtx, pSlug) { var status = pCtx.getStatus(); if (status.isWorktree) return; - if (paletteUser && onGetProjectAccess) { - var pAccess = onGetProjectAccess(pSlug); - if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return; - } + var pAccess = accessibleProject(principal, pSlug); + if (!pAccess) return; var accessibleSessions = []; pCtx.sm.sessions.forEach(function (session) { if (session.hidden) return; - if (paletteUser) { - var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null; - if (!users.canAccessSession(paletteUser.id, session, sAccess)) return; - } + if (!canSeeSession(principal, session, pAccess)) return; smBySession.set(session, pCtx.sm); accessibleSessions.push(session); }); diff --git a/lib/server.js b/lib/server.js index b7e3fe97..45be6ba5 100644 --- a/lib/server.js +++ b/lib/server.js @@ -20,6 +20,8 @@ var { detectLite } = require("./lite-detect"); var { readCappedBody } = require("./utils"); var { computeAllProjectSessions } = require("./server-hub-sessions"); var { resolveEffectiveProtocol } = require("./effective-protocol"); +var { createProjectAccess } = require("./project-access"); +var { checkWsOrigin } = require("./ws-origin"); var https = require("https"); var pkg = require("../package.json"); @@ -129,6 +131,10 @@ function extractSlug(urlPath) { // Sec-WebSocket-Protocol list; this extracts the raw ticket back out. var WS_TICKET_SUBPROTOCOL_PREFIX = "clagentic.auth."; +// Stands for the single-user owner while a WebSocket upgrade is decided: the +// login gate admitted the request and there is no user record to attach. +var IMPLICIT_OWNER = Object.freeze({}); + /** * Extract a ws-ticket (lr-de5fcb) from a raw Sec-WebSocket-Protocol header * value. Returns null if the header is absent or carries no ticket-shaped @@ -231,6 +237,9 @@ function createServer(opts) { // lr-20e71c: operator-declared trust boundary for reading X-Forwarded-Proto // from a reverse proxy. See lib/effective-protocol.js. var trustedProxy = !!opts.trustedProxy; + // Operator-configured origins (daemon.json allowedOrigins) that may open a + // WebSocket in addition to the daemon's own host; see lib/ws-origin.js. + var allowedOrigins = Array.isArray(opts.allowedOrigins) ? opts.allowedOrigins : []; // lr-2ea2a7: daemon.json knob overriding the in-heap session-history tail cap. var historyInMemMax = opts.historyInMemMax; var onAddProject = opts.onAddProject || null; @@ -282,6 +291,16 @@ function createServer(opts) { var onUserDeleted = opts.onUserDeleted || null; var getRemovedProjects = opts.getRemovedProjects || function () { return []; }; + // The one answer to "who is this and may they use that project": every + // check below goes through it. "No user" is decided by the daemon's mode + // (the single-user owner is implicit, a missing user in multi-user mode is + // a stranger), and a lookup that cannot be completed is a refusal. + var projectAccess = createProjectAccess({ + users: users, + onGetProjectAccess: onGetProjectAccess, + isMultiUser: typeof opts.isMultiUser === "function" ? opts.isMultiUser : users.isMultiUser, + }); + // --- Auth module --- var auth = serverAuth.attachAuth({ users: users, @@ -342,7 +361,8 @@ function createServer(opts) { users: users, projects: projects, getMultiUserFromReq: getMultiUserFromReq, - onGetProjectAccess: onGetProjectAccess, + isRequestAuthed: isRequestAuthed, + projectAccess: projectAccess, }); // --- Push module (global) --- @@ -645,28 +665,17 @@ function createServer(opts) { var reqUser = getMultiUserFromReq(req); // Check for last-visited project cookie var lastProject = parseCookies(req)["clagentic_last_project"] || parseCookies(req)["clay_last_project"]; - if (lastProject && projects.has(lastProject)) { - if (reqUser && onGetProjectAccess) { - var lpAccess = onGetProjectAccess(lastProject); - if (lpAccess && !lpAccess.error && users.canAccessProject(reqUser.id, lpAccess)) { - targetSlug = lastProject; - } - } else { - targetSlug = lastProject; - } + var reqUserId = reqUser ? reqUser.id : null; + // isRequestAuthed(req) passed above: that is the proof an implicit owner needs. + var rootProof = { authenticated: true }; + if (lastProject && projects.has(lastProject) && projectAccess.canAccess(reqUserId, lastProject, rootProof)) { + targetSlug = lastProject; } // Fall back to first accessible project if (!targetSlug) { projects.forEach(function (ctx, s) { if (targetSlug) return; - if (reqUser && onGetProjectAccess) { - var access = onGetProjectAccess(s); - if (access && !access.error && users.canAccessProject(reqUser.id, access)) { - targetSlug = s; - } - } else { - targetSlug = s; - } + if (projectAccess.canAccess(reqUserId, s, rootProof)) targetSlug = s; }); } if (targetSlug) { @@ -798,16 +807,16 @@ function createServer(opts) { }).protocol; res.setHeader("Set-Cookie", "clagentic_last_project=" + slug + "; Path=/; SameSite=Strict; Max-Age=31536000" + (cookieEffectiveProtocol === "https" ? "; Secure" : "")); - // Check project access for HTTP requests - if (onGetProjectAccess) { + // Check project access for HTTP requests. The local MCP bridge carries no + // login (it is a child process of the daemon's own host); every other + // request must belong to a user who may use this project. + if (!isMcpBridgeLocal) { var httpUser = getMultiUserFromReq(req); - if (httpUser) { - var httpAccess = onGetProjectAccess(slug); - if (httpAccess && !httpAccess.error && !users.canAccessProject(httpUser.id, httpAccess)) { - res.writeHead(302, { "Location": "/" }); - res.end(); - return; - } + // Not the bridge, so the isRequestAuthed gate above admitted this request. + if (!projectAccess.canAccess(httpUser ? httpUser.id : null, slug, { authenticated: true })) { + res.writeHead(302, { "Location": "/" }); + res.end(); + return; } } @@ -819,6 +828,9 @@ function createServer(opts) { // Attach user info for project HTTP handler req._clagenticUser = getMultiUserFromReq(req); + // Reaching here means the login gate admitted the request or it is the + // local MCP bridge: the two proofs an implicit owner may rest on. + req._clagenticAuthenticated = true; // Try project HTTP handler first (APIs) var origUrl = req.url; @@ -919,34 +931,33 @@ function createServer(opts) { }, }); + // Every outcome of an upgrade answers or destroys the socket: nothing thrown + // while deciding can leave the client hanging or reach the process. server.on("upgrade", function (req, socket, head) { - // Origin validation (CSRF prevention) - var origin = req.headers.origin; - if (origin) { - try { - var originUrl = new URL(origin); - var originPort = String(originUrl.port || (originUrl.protocol === "https:" ? "443" : "80")); - // Extract port from Host header for reverse proxy support. - // Use URL parser to correctly handle IPv6 addresses (e.g. [::1]) - // and infer default port from origin protocol (not backend tlsOptions) - // so TLS-terminating proxies on :443 with HTTP backends work. - var hostPort; - try { - var hostUrl = new URL(originUrl.protocol + "//" + (req.headers.host || "")); - hostPort = String(hostUrl.port || (originUrl.protocol === "https:" ? "443" : "80")); - } catch (e2) { - hostPort = String(portNum); - } - if (originPort !== String(portNum) && originPort !== hostPort) { - socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); - socket.destroy(); - return; - } - } catch (e) { - socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); - socket.destroy(); - return; - } + try { + handleUpgrade(req, socket, head); + } catch (err) { + console.error("[server] WS upgrade failed:", err && err.message ? err.message : err); + if (socket.writable) socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); + socket.destroy(); + } + }); + + function handleUpgrade(req, socket, head) { + // Origin validation (CSRF prevention): the full origin, not just its port, + // must belong to this daemon or be allow-listed. See lib/ws-origin.js for + // the rules, including the no-Origin case for non-browser clients. + var originCheck = checkWsOrigin({ + headers: req.headers, + tls: !!tlsOptions, + trustedProxy: trustedProxy, + allowedOrigins: allowedOrigins, + }); + if (!originCheck.ok) { + if (debug) console.log("[server] WS rejected: " + originCheck.reason); + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; } // Auth (lr-de5fcb): primary path is the relay_auth_user session cookie @@ -974,6 +985,13 @@ function createServer(opts) { wsTicketUser = auth.consumeWsTicket(offeredTicket); } var wsAuthedUser = wsCookieUser || wsTicketUser; + // The same login gate as the HTTP routes decides who is authenticated. A + // request it admits that carries no user record is the implicit owner in + // single-user mode (IMPLICIT_OWNER stands in until the connection is bound + // below) and nobody in multi-user mode (lib/project-access.js). + if (!wsAuthedUser && isRequestAuthed(req) && projectAccess.principalFor(null, { authenticated: true })) { + wsAuthedUser = IMPLICIT_OWNER; + } if (!wsAuthedUser) { // Observability (DRUMMER-flagged, lr-de5fcb): debug-gated only, never @@ -1038,21 +1056,16 @@ function createServer(opts) { // Attach user info to the WS connection. Reuse wsAuthedUser (resolved // above from the cookie or, on mobile, the ticket fallback) rather than // re-deriving from the cookie — a ticket-authed request has no cookie. - var wsUser = wsAuthedUser; - // Check project access - if (wsUser && onGetProjectAccess) { - // For worktree projects, inherit access from parent - var accessSlug = (wsSlug.indexOf("--") !== -1) ? wsSlug.split("--")[0] : wsSlug; - var projectAccess = onGetProjectAccess(accessSlug); - if (debug) console.log("[server] WS access check:", wsSlug, "user:", wsUser.id, "role:", wsUser.role, "visibility:", projectAccess && projectAccess.visibility, "ownerId:", projectAccess && projectAccess.ownerId, "allowed:", projectAccess && projectAccess.allowedUsers); - if (projectAccess && !projectAccess.error) { - if (!users.canAccessProject(wsUser.id, projectAccess)) { - if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug); - socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); - socket.destroy(); - return; - } - } + var wsUser = wsAuthedUser === IMPLICIT_OWNER ? null : wsAuthedUser; + // Check project access. A worktree slug resolves to its parent's access + // inside onGetProjectAccess (daemon.js), so no slug is special-cased here. + // Past the 401 above this upgrade is authenticated (cookie, ticket or the + // login gate), which is the proof an implicit owner needs. + if (!projectAccess.canAccess(wsUser ? wsUser.id : null, wsSlug, { authenticated: true })) { + if (debug) console.log("[server] WS rejected: access denied for", wsUser ? wsUser.id : "(no user)", "on", wsSlug); + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; } wss.handleUpgrade(req, socket, head, function (ws) { @@ -1090,6 +1103,9 @@ function createServer(opts) { return origEmit.apply(ws, arguments); }; ws._clagenticUser = wsUser; // attach user context + // The only place a connection is marked authenticated: handleUpgrade + // answered 401 above for anything that failed the login check. + ws._clagenticAuthenticated = true; var remoteAddr = req.socket.remoteAddress || ""; ws._clagenticLocal = (remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1"); // lr-20e71c: resolve the real external protocol for THIS connection at @@ -1111,7 +1127,7 @@ function createServer(opts) { } ctx.handleConnection(ws, wsUser); }); - }); + } // --- Cross-project unread tracking --- // WeakMap tracks how many done events @@ -1238,17 +1254,7 @@ function createServer(opts) { // Always send per-client to include cross-project unread counts projects.forEach(function (ctx, projSlug) { ctx.forEachClient(function (ws) { - var filtered = allProjectsList; - if (onGetProjectAccess) { - var wsUser = ws._clagenticUser; - if (wsUser) { - filtered = allProjectsList.filter(function (p) { - var access = onGetProjectAccess(p.slug); - if (!access || access.error) return true; - return users.canAccessProject(wsUser.id, access); - }); - } - } + var filtered = projectAccess.filterProjectList(ws._clagenticUser ? ws._clagenticUser.id : null, allProjectsList, projectAccess.authOf(ws)); // Attach per-project unread counts for this client. lr-0aa7b6: // crossProjectUnread is now keyed per-session ("slug::localId"), // not per-project — the Projects-list badge total is a rollup @@ -1303,18 +1309,15 @@ function createServer(opts) { tls: !!tlsOptions, historyInMemMax: historyInMemMax, getProjectCount: function () { return projects.size; }, - getProjectList: function (userId) { - var list = []; - projects.forEach(function (ctx, s) { - var status = ctx.getStatus(); - if (userId && onGetProjectAccess) { - var access = onGetProjectAccess(s); - if (access && !access.error && !users.canAccessProject(userId, access)) return; - } - list.push(status); - }); - return list; + // The projects `userId` may see. No user means every project for the + // implicit single-user owner and none in multi-user mode. + getProjectList: function (userId, proof) { + return projectAccess.filterProjectList(userId, getProjects(), proof); + }, + canAccessProject: function (userId, projSlug, proof) { + return projectAccess.canAccess(userId, projSlug, proof); }, + projectAccess: projectAccess, // includeSelf: when true, the calling project's own sessions are included // in the uniform pass (so callers need not special-case self). The Hub // recent-sessions list uses this to gather every project identically — @@ -1329,13 +1332,9 @@ function createServer(opts) { // enforce per-project access or private-session visibility, leaking // cross-project and private sessions into the Home Hub recent-sessions // list. Fails CLOSED: no userId (or no onGetProjectAccess wiring) means - // no sessions are returned. There is no legitimate no-auth caller of - // this function -- the WS upgrade handler above rejects any connection - // without an authenticated user (wsAuthedUser) with 401 before - // ws._clagenticUser can ever be set, including in single-user/PIN mode - // (still a real authenticated user, just one account) -- so failing - // closed here never breaks a real deployment mode. - getAllProjectSessions: function (includeSelf, userId) { + // no sessions are returned, except for the implicit single-user owner + // (no user record, mode decided in lib/project-access.js). + getAllProjectSessions: function (includeSelf, userId, proof) { return computeAllProjectSessions({ projects: projects, users: users, @@ -1343,11 +1342,15 @@ function createServer(opts) { callerSlug: slug, includeSelf: includeSelf, userId: userId, + implicitOwner: !userId && !!projectAccess.principalFor(null, proof), }); }, - getHubSchedules: function () { + // Schedules of the projects `userId` may see; no user and no proof of + // login, no schedules. + getHubSchedules: function (userId, proof) { var allSchedules = []; projects.forEach(function (ctx, s) { + if (!projectAccess.canAccess(userId, s, proof)) return; var status = ctx.getStatus(); var recs = ctx.getSchedules(); for (var i = 0; i < recs.length; i++) { @@ -1624,21 +1627,14 @@ function createServer(opts) { projects.forEach(function (ctx) { ctx.forEachClient(function (ws) { var userId = ws._clagenticUser ? ws._clagenticUser.id : null; - var key = userId || "__anon__"; + var wsProof = projectAccess.authOf(ws); + var key = userId || (wsProof.authenticated ? "__anon__" : "__unproven__"); if (sentUsers[key]) { // Already computed for this user, just send the cached msg ws.send(sentUsers[key]); return; } - var filteredProjects = []; - projects.forEach(function (pCtx, s) { - var status = pCtx.getStatus(); - if (userId && onGetProjectAccess) { - var access = onGetProjectAccess(s); - if (access && !access.error && !users.canAccessProject(userId, access)) return; - } - filteredProjects.push(status); - }); + var filteredProjects = projectAccess.filterProjectList(userId, getProjects(), wsProof); // Per-user DM data var userDmFavorites = userId ? users.getDmFavorites(userId) : []; var userDmHidden = userId ? users.getDmHidden(userId) : []; @@ -1669,7 +1665,23 @@ function createServer(opts) { }, 300); } + // A project list is never sent as one shared message: every client gets + // the entries its user may see, whichever call site asked for the broadcast. + function broadcastProjectsUpdated(msg) { + projects.forEach(function (ctx) { + ctx.forEachClient(function (ws) { + if (ws.readyState !== 1) return; + var visible = projectAccess.filterProjectList(ws._clagenticUser ? ws._clagenticUser.id : null, msg.projects, projectAccess.authOf(ws)); + ws.send(JSON.stringify(Object.assign({}, msg, { projects: visible, projectCount: visible.length }))); + }); + }); + } + function broadcastAll(msg) { + if (msg && msg.type === "projects_updated" && Array.isArray(msg.projects)) { + broadcastProjectsUpdated(msg); + return; + } projects.forEach(function (ctx) { ctx.send(msg); }); @@ -1775,7 +1787,9 @@ function createServer(opts) { } } cleanupOldImages(); - setInterval(cleanupOldImages, 24 * 60 * 60 * 1000); + // unref: the HTTP server keeps the daemon alive; this timer must not keep a + // process alive that has closed its server (tests, shutdown). + setInterval(cleanupOldImages, 24 * 60 * 60 * 1000).unref(); return { server: server, diff --git a/lib/sessions.js b/lib/sessions.js index 888f9942..95cadff5 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -95,7 +95,9 @@ function createSessionManager(opts) { var slashCommands = null; // shared across sessions (deprecated, use slashCommandsByVendor) var slashCommandsByVendor = {}; // vendor -> array of slash commands var skillNames = null; // Claude-only skills to filter from slash menu - var permissionRequestIndex = {}; // requestId -> sessionLocalId (O(1) lookup) + // requestId -> sessionLocalId (O(1) lookup). Keyed by ids that arrive from + // clients, so it has no prototype to answer for "constructor" or "__proto__". + var permissionRequestIndex = Object.create(null); // Sole writer of every session's operator-prompt state; see // lib/prompt-registry.js. Built before any session exists because // construction itself may create and switch to one. @@ -729,8 +731,8 @@ function createSessionManager(opts) { cliSessionId: m.cliSessionId, blocks: {}, sentToolResults: {}, - pendingPermissions: {}, - pendingAskUser: {}, + pendingPermissions: Object.create(null), + pendingAskUser: Object.create(null), // Hydrate previously-granted "allow for session" tool decisions from // durable state (lr-8b2e); default to {} only when none was saved so // grants that were never given remain empty, not re-prompted. @@ -875,9 +877,9 @@ function createSessionManager(opts) { cliSessionId: null, blocks: {}, sentToolResults: {}, - pendingPermissions: {}, - pendingAskUser: {}, - allowedTools: {}, + pendingPermissions: Object.create(null), + pendingAskUser: Object.create(null), + allowedTools: Object.create(null), isProcessing: false, title: "", titleAutoGenerated: false, @@ -910,9 +912,9 @@ function createSessionManager(opts) { cliSessionId: null, blocks: {}, sentToolResults: {}, - pendingPermissions: {}, - pendingAskUser: {}, - allowedTools: {}, + pendingPermissions: Object.create(null), + pendingAskUser: Object.create(null), + allowedTools: Object.create(null), isProcessing: false, title: "", titleAutoGenerated: false, @@ -1385,8 +1387,8 @@ function createSessionManager(opts) { cliSessionId: cliSessionId, blocks: {}, sentToolResults: {}, - pendingPermissions: {}, - pendingAskUser: {}, + pendingPermissions: Object.create(null), + pendingAskUser: Object.create(null), // Hydrate previously-granted "allow for session" tool decisions from // durable state (lr-8b2e); default to {} only when none was saved. // Sanitized (lr-8b2e hardening) so a malformed/injected persisted @@ -1445,11 +1447,14 @@ function createSessionManager(opts) { return _parseSessionFileLines(session).lines; } - function searchSessions(query) { + // canSee (optional): predicate over a session; a session it rejects is + // skipped before its title or any of its content is examined. + function searchSessions(query, canSee) { if (!query) return []; var q = query.toLowerCase(); var results = []; sessions.forEach(function (session) { + if (canSee && !canSee(session)) return; var titleMatch = (session.title || "New Session").toLowerCase().indexOf(q) !== -1; var contentMatch = false; if (titleMatch) { diff --git a/lib/users-permissions.js b/lib/users-permissions.js index da62dc21..8a9ff2dc 100644 --- a/lib/users-permissions.js +++ b/lib/users-permissions.js @@ -65,16 +65,19 @@ function attachPermissions(deps) { function canAccessProject(userId, project) { if (!project) return false; - // Public projects are accessible to all authenticated users - if (!project.visibility || project.visibility === "public") return true; - // Admin always has access - var user = findUserById(userId); - if (user && user.role === "admin") return true; + // Only an explicit "public" opens a project to every authenticated user. + // A record with no (or an unrecognised) visibility is treated as private + // so a missing field can never widen access. + if (project.visibility === "public") return true; // Owner always has access to their own project if (project.ownerId && project.ownerId === userId) return true; // Private project -- check allowedUsers var allowed = project.allowedUsers || []; - return allowed.indexOf(userId) >= 0; + if (allowed.indexOf(userId) >= 0) return true; + // Admin always has access. Checked last: it reads the user store, and + // this runs for every message a connection sends. + var user = findUserById(userId); + return !!(user && user.role === "admin"); } function getAccessibleProjects(userId, projects) { diff --git a/lib/users.js b/lib/users.js index 66db3247..7c27b1c3 100644 --- a/lib/users.js +++ b/lib/users.js @@ -140,6 +140,14 @@ function hasAdmin() { return !!findAdmin(); } +// The daemon's mode, as stored with the users. An install that predates the +// flag, or whose file is absent, is multi-user (see loadUsers); only an +// explicit false marks a single-user install. A read error propagates so the +// caller can treat "mode unknown" as the stricter mode. +function isMultiUser() { + return loadUsers().multiUser !== false; +} + function findUserById(id) { var data = loadUsers(); for (var i = 0; i < data.users.length; i++) { @@ -438,6 +446,7 @@ module.exports = { createAdmin: createAdmin, findAdmin: findAdmin, hasAdmin: hasAdmin, + isMultiUser: isMultiUser, findUserById: findUserById, findUserByEmail: findUserByEmail, authenticateUser: authenticateUser, diff --git a/lib/utils.js b/lib/utils.js index 34ca4b5e..b86971fb 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -67,10 +67,12 @@ function resolveEncodedFile(baseDir, cwd, ext) { * * Keeps only entries whose key is a string and whose value is strictly * boolean `true`; drops everything else. A non-object input (including - * null/undefined/arrays) yields {} rather than throwing. + * null/undefined/arrays) yields an empty map rather than throwing. The map + * has no prototype: its keys are tool names, and a name like "constructor" + * must not be found as an inherited entry. */ function sanitizeAllowedTools(value) { - var out = {}; + var out = Object.create(null); if (!value || typeof value !== "object" || Array.isArray(value)) return out; for (var key in value) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; diff --git a/lib/ws-origin.js b/lib/ws-origin.js new file mode 100644 index 00000000..9bd1a999 --- /dev/null +++ b/lib/ws-origin.js @@ -0,0 +1,99 @@ +// WebSocket upgrade origin check (defence in depth against cross-site +// WebSocket hijacking; every upgrade must also carry a valid login). +// +// Rules: +// - No Origin header: a non-browser client (CLI, relay, MCP bridge). Allowed; +// the login still applies. +// - An Origin on the operator's allowedOrigins list: allowed. +// - Otherwise the Origin's host AND port must equal those of the request's +// Host header (or, only when the operator declared a trusted proxy, of its +// X-Forwarded-Host). A bare port match is not enough: a sibling +// subdomain that is served on the same port is a different origin. +// - Scheme is compared only where the daemon knows it: it terminates TLS +// itself (https required), or a trusted proxy reported it +// (X-Forwarded-Proto). Behind an unconfigured TLS-terminating proxy the +// scheme the daemon sees is not the browser's, so it is not compared; the +// origin's own scheme only supplies the default port. +// - Any Origin that is not a parsable http(s) origin (including "null" and +// extension origins such as chrome-extension://...) is refused unless it +// is on the allowed list. + +function parseOrigin(value) { + try { + var url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url; + } catch (e) { + return null; + } +} + +function defaultPort(protocol) { + return protocol === "https:" ? "443" : "80"; +} + +// "scheme://host:port" with the default port made explicit and the host +// lower-cased, or null when value is not an http(s) origin. +function normalizeOrigin(value) { + var url = parseOrigin(value); + if (!url) return null; + return url.protocol + "//" + url.hostname + ":" + (url.port || defaultPort(url.protocol)); +} + +function hostMatches(originUrl, hostHeader) { + if (!hostHeader) return false; + var hostUrl; + try { + hostUrl = new URL(originUrl.protocol + "//" + hostHeader); + } catch (e) { + return false; + } + var originPort = originUrl.port || defaultPort(originUrl.protocol); + var hostPort = hostUrl.port || defaultPort(originUrl.protocol); + return originUrl.hostname === hostUrl.hostname && originPort === hostPort; +} + +function firstValue(header) { + if (typeof header !== "string") return ""; + return header.split(",")[0].trim(); +} + +// o: { headers, tls, trustedProxy, allowedOrigins } +// Returns { ok: boolean, reason?: string }. +function checkWsOrigin(o) { + var headers = o.headers || {}; + var origin = headers.origin; + if (origin === undefined || origin === "") return { ok: true }; + if (typeof origin !== "string") return { ok: false, reason: "malformed origin" }; + + var allowed = Array.isArray(o.allowedOrigins) ? o.allowedOrigins : []; + var normalized = normalizeOrigin(origin); + for (var i = 0; i < allowed.length; i++) { + if (typeof allowed[i] !== "string") continue; + // Non-http(s) entries (extension origins) are compared as written. + if (allowed[i] === origin) return { ok: true }; + if (normalized && normalizeOrigin(allowed[i]) === normalized) return { ok: true }; + } + + var originUrl = parseOrigin(origin); + if (!originUrl) return { ok: false, reason: "origin is not an http(s) origin" }; + + var hostOk = hostMatches(originUrl, headers.host); + if (!hostOk && o.trustedProxy) { + hostOk = hostMatches(originUrl, firstValue(headers["x-forwarded-host"])); + } + if (!hostOk) return { ok: false, reason: "origin host does not match the request host" }; + + var knownScheme = null; + if (o.tls) knownScheme = "https:"; + else if (o.trustedProxy) { + var forwardedProto = firstValue(headers["x-forwarded-proto"]).toLowerCase(); + if (forwardedProto === "https" || forwardedProto === "http") knownScheme = forwardedProto + ":"; + } + if (knownScheme && originUrl.protocol !== knownScheme) { + return { ok: false, reason: "origin scheme does not match the connection" }; + } + return { ok: true }; +} + +module.exports = { checkWsOrigin: checkWsOrigin }; diff --git a/lib/yoke/adapters/claude-worker.js b/lib/yoke/adapters/claude-worker.js index 8d0e5b2c..f7157912 100644 --- a/lib/yoke/adapters/claude-worker.js +++ b/lib/yoke/adapters/claude-worker.js @@ -98,10 +98,12 @@ var sdkModule = null; var queryInstance = null; var messageQueue = null; var abortController = null; -var pendingPermissions = {}; // requestId -> resolve -var pendingAskUser = {}; // toolUseId -> resolve -var pendingElicitations = {}; // requestId -> resolve -var pendingMcpToolCalls = {}; // requestId -> { resolve, reject } +// Keyed by ids that come in over the daemon socket: no prototype, so an id +// such as "constructor" finds nothing instead of an inherited function. +var pendingPermissions = Object.create(null); // requestId -> resolve +var pendingAskUser = Object.create(null); // toolUseId -> resolve +var pendingElicitations = Object.create(null); // requestId -> resolve +var pendingMcpToolCalls = Object.create(null); // requestId -> { resolve, reject } var conn = null; var buffer = ""; // Reference to the claude CLI child process spawned by spawnClaudeCodeProcess. @@ -615,10 +617,10 @@ async function handleQueryStart(msg) { queryInstance = null; messageQueue = null; abortController = null; - pendingPermissions = {}; - pendingAskUser = {}; - pendingElicitations = {}; - pendingMcpToolCalls = {}; + pendingPermissions = Object.create(null); + pendingAskUser = Object.create(null); + pendingElicitations = Object.create(null); + pendingMcpToolCalls = Object.create(null); } } diff --git a/lib/yoke/adapters/claude.js b/lib/yoke/adapters/claude.js index 43197a82..2c3594fd 100644 --- a/lib/yoke/adapters/claude.js +++ b/lib/yoke/adapters/claude.js @@ -792,7 +792,9 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) // Pending request/response correlation for handle methods that need a // result from the worker (e.g. rewindFiles). Each entry is keyed by a // requestId and holds { resolve, reject } of the in-flight Promise. - var pendingRewinds = {}; + // These three tables are keyed by ids the worker echoes back, so they have + // no prototype to answer for "constructor" or "__proto__". + var pendingRewinds = Object.create(null); // lr-f22787: same correlation pattern as pendingRewinds, for setModel. // Without this, an invalid/unentitled model ID sent to a worker session @@ -802,13 +804,13 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) // Promise.resolve() immediately without ever waiting for that reply, so // the failure had no path back to the caller (sdk-bridge.js's setModel, // then project-sessions.js) to surface to the user. - var pendingSetModel = {}; + var pendingSetModel = Object.create(null); // ":" -> AbortController whose signal is handed to // the daemon-side callback, so the daemon-side prompt ends when the worker // stops waiting for it: its own timeout, the SDK cancelling the callback, // or the worker's query ending. - var promptAborts = {}; + var promptAborts = Object.create(null); function abortPrompt(key, reason) { var ac = promptAborts[key]; diff --git a/lib/yoke/codex-app-server.js b/lib/yoke/codex-app-server.js index 48cc924f..0fd23cc4 100644 --- a/lib/yoke/codex-app-server.js +++ b/lib/yoke/codex-app-server.js @@ -98,7 +98,8 @@ function CodexAppServer(executablePath, opts) { this.proc = null; this.rl = null; this.nextId = 1; - this.pendingRequests = {}; // id -> { resolve, reject, timer } + // Keyed by ids the app-server echoes back: no prototype to answer for them. + this.pendingRequests = Object.create(null); // id -> { resolve, reject, timer } // eventHandler kept for backward-compat but superseded by the multiplexer below. // Use addEventHandler / removeEventHandler instead of setting this directly. this.eventHandler = null; @@ -300,7 +301,7 @@ CodexAppServer.prototype._rejectAllPending = function(err) { if (pending.timer) clearTimeout(pending.timer); pending.reject(err); } - this.pendingRequests = {}; + this.pendingRequests = Object.create(null); }; // Register an event handler for a specific thread (or null to receive all events). diff --git a/lib/yoke/index.js b/lib/yoke/index.js index 584c0aac..01feb4f4 100644 --- a/lib/yoke/index.js +++ b/lib/yoke/index.js @@ -34,6 +34,14 @@ function wrapCreateQuery(adapter, defaultCwd) { }; } +// Vendors createAdapter can build. A vendor name that arrives from a client is +// checked against this list before it is used as a key or handed to a lookup. +var KNOWN_VENDORS = ["claude", "codex"]; + +function isKnownVendor(vendor) { + return typeof vendor === "string" && KNOWN_VENDORS.indexOf(vendor) !== -1; +} + /** * Create a YOKE adapter. * @@ -307,6 +315,7 @@ async function lazyCreateAdapter(adapters, vendor, opts) { } module.exports = { + isKnownVendor: isKnownVendor, createAdapter: createAdapter, createAdapters: createAdapters, lazyCreateAdapter: lazyCreateAdapter, diff --git a/test/access-gate-integration.test.js b/test/access-gate-integration.test.js new file mode 100644 index 00000000..71f35f3c --- /dev/null +++ b/test/access-gate-integration.test.js @@ -0,0 +1,349 @@ +// Project access control, end to end: a real server, real login cookies and +// real WebSocket clients. Each test names the gap it closes; every one of them +// failed against the code before the central access gate and passes now. +// +// Caller shapes covered here: the single-user owner (an admin, the live +// deployment), multi-user members and owners, worktree slugs, WS messages with +// and without targetSlug, plain and unauthenticated HTTP, and the local MCP +// bridge. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var harness = require("./access-harness"); + +var H = null; + +function sessionCount(slug) { + var n = null; + H.relay.forEachProject(function (ctx, s) { if (s === slug) n = ctx.sm.sessions.size; }); + return n; +} + +function projectCtx(slug) { + var found = null; + H.relay.forEachProject(function (ctx, s) { if (s === slug) found = ctx; }); + return found; +} + +function slugsOf(msg) { + return msg.projects.map(function (p) { return p.slug; }).sort(); +} + +test.before(async function () { + H = await harness.start({ + users: [ + { key: "admin", username: "owner-admin", role: "admin" }, + { key: "alice", username: "alice", permissions: { sessionDelete: true } }, + { key: "bob", username: "bob", permissions: { sessionDelete: true } }, + { key: "carol", username: "carol", permissions: { fileBrowser: false } }, + ], + projects: [ + { slug: "pub", visibility: "public", ownerKey: "bob", files: { "a.png": "png", "notes.txt": "hello" } }, + { slug: "priv", visibility: "private", ownerKey: "bob", files: { "secret.png": "png" } }, + { slug: "priv--wt", parent: "priv" }, + { slug: "ghost", noRecord: true }, + { slug: "nov", ownerKey: "bob" }, + ], + }); +}); + +test.after(async function () { + if (H) await H.stop(); +}); + +// --- targetSlug forwarding ------------------------------------------------- + +test("a message naming a project the user cannot use is refused, and nothing happens there", async function () { + var c = await H.connect("pub", "alice"); + assert.ok(c.ok); + var before = sessionCount("priv"); + c.send({ type: "new_session", targetSlug: "priv" }); + var err = await c.waitFor(function (m) { return m.type === "error"; }); + assert.match(err.text, /access denied/i); + await harness.settle(); + assert.equal(sessionCount("priv"), before, "the private project was not touched"); +}); + +test("a message naming a project that does not exist is dropped, not handled in the current project", async function () { + var c = await H.connect("pub", "alice"); + var before = sessionCount("pub"); + c.send({ type: "new_session", targetSlug: "no-such-project" }); + await c.waitFor(function (m) { return m.type === "error"; }); + await harness.settle(); + assert.equal(sessionCount("pub"), before, "the message did not fall through to the current project"); +}); + +test("a message naming a project the user may use is handled there, with the slug removed", async function () { + var c = await H.connect("pub", "bob"); + var before = sessionCount("priv"); + var beforeHere = sessionCount("pub"); + c.send({ type: "new_session", targetSlug: "priv" }); + await harness.settle(400); + assert.equal(sessionCount("priv"), before + 1, "the owner's message reached the project it named"); + assert.equal(sessionCount("pub"), beforeHere, "and was not also handled in the current project"); +}); + +test("a message with no targetSlug, or one naming the current project, is handled in the current project", async function () { + var c = await H.connect("pub", "alice"); + var before = sessionCount("pub"); + c.send({ type: "new_session" }); + await harness.settle(300); + c.send({ type: "new_session", targetSlug: "pub" }); + await harness.settle(300); + assert.equal(sessionCount("pub"), before + 2); +}); + +test("a targetSlug that is not a string is refused", async function () { + var c = await H.connect("pub", "alice"); + var before = sessionCount("pub"); + c.send({ type: "new_session", targetSlug: { toString: "x" } }); + await c.waitFor(function (m) { return m.type === "error"; }); + await harness.settle(); + assert.equal(sessionCount("pub"), before); +}); + +test("a frame that is not a JSON object does not take the server down", async function () { + var c = await H.connect("pub", "alice"); + c.ws.send("null"); + c.ws.send("17"); + c.ws.send("[1,2]"); + c.ws.send("\"text\""); + await harness.settle(200); + c.send({ type: "new_session" }); + await harness.settle(200); + var again = await H.connect("pub", "alice"); + assert.ok(again.ok, "the server still accepts connections"); +}); + +// --- Access lookups fail closed -------------------------------------------- + +test("a project whose access cannot be established refuses the WebSocket upgrade, even for an administrator", async function () { + assert.equal((await H.connect("ghost", "alice")).status, 403); + assert.equal((await H.connect("ghost", "admin")).status, 403); +}); + +test("a project record with no visibility is private: only its owner and administrators connect", async function () { + assert.equal((await H.connect("nov", "alice")).status, 403); + assert.ok((await H.connect("nov", "bob")).ok); + assert.ok((await H.connect("nov", "admin")).ok); +}); + +test("a worktree of a private project takes its parent's access", async function () { + assert.equal((await H.connect("priv--wt", "alice")).status, 403); + assert.ok((await H.connect("priv--wt", "bob")).ok); +}); + +test("the single-user owner (an administrator) reaches every project that has an access record", async function () { + assert.ok((await H.connect("pub", "admin")).ok); + assert.ok((await H.connect("priv", "admin")).ok); + assert.ok((await H.connect("priv--wt", "admin")).ok); +}); + +test("the HTTP project route refuses a project whose access cannot be established", async function () { + var ghost = await H.request("GET", "/p/ghost/", "alice"); + assert.equal(ghost.status, 302); + assert.equal(ghost.headers.location, "/"); + assert.equal((await H.request("GET", "/p/ghost/", "admin")).status, 302); + assert.equal((await H.request("GET", "/p/priv/", "alice")).status, 302); + assert.equal((await H.request("GET", "/p/pub/", "alice")).status, 200); + assert.equal((await H.request("GET", "/p/priv/", "admin")).status, 200); +}); + +test("the root redirect never lands on a project the user cannot use", async function () { + var res = await H.request("GET", "/", "alice", undefined, { Cookie: H.cookies.alice + "; clagentic_last_project=ghost" }); + assert.equal(res.status, 302); + assert.equal(res.headers.location, "/p/pub/"); +}); + +test("the local MCP bridge keeps working with no login; other unauthenticated project calls are refused", async function () { + var bridge = await H.request("POST", "/p/priv/api/mcp-bridge", null, { action: "no-such-action" }); + assert.equal(bridge.status, 400, "reaches the bridge handler without a login"); + var upload = await H.request("POST", "/p/pub/api/upload", null, { name: "x", data: "eA==" }); + assert.equal(upload.status, 401); +}); + +test("the palette leaves out projects whose access cannot be established and sessions the user cannot see", async function () { + var ghost = projectCtx("ghost").sm.createSessionRaw({ ownerId: H.people.bob.id }); + ghost.title = "palette-ghost-session"; + var pub = projectCtx("pub").sm.createSessionRaw({ ownerId: H.people.bob.id, sessionVisibility: "private" }); + pub.title = "palette-bobs-private-session"; + var shared = projectCtx("pub").sm.createSessionRaw({ ownerId: H.people.bob.id }); + shared.title = "palette-shared-session"; + + var recent = JSON.parse((await H.request("GET", "/api/palette/search", "alice")).body).results; + var titles = recent.map(function (r) { return r.sessionTitle; }); + assert.ok(titles.indexOf("palette-shared-session") !== -1, "alice sees a shared session in a project she can use"); + assert.equal(titles.indexOf("palette-ghost-session"), -1); + assert.equal(titles.indexOf("palette-bobs-private-session"), -1); + + var found = JSON.parse((await H.request("GET", "/api/palette/search?q=palette", "alice")).body).results; + var foundTitles = found.map(function (r) { return r.sessionTitle; }); + assert.equal(foundTitles.indexOf("palette-ghost-session"), -1); + assert.equal(foundTitles.indexOf("palette-bobs-private-session"), -1); + assert.ok(foundTitles.indexOf("palette-shared-session") !== -1); + + var asBob = JSON.parse((await H.request("GET", "/api/palette/search", "bob")).body).results.map(function (r) { return r.sessionTitle; }); + assert.ok(asBob.indexOf("palette-bobs-private-session") !== -1, "an owner still sees their own private session"); +}); + +// --- Project lists reach each user filtered ---------------------------------- + +test("a project-list broadcast gives each client only the projects its user may see", async function () { + var alice = await H.connect("pub", "alice"); + var bob = await H.connect("pub", "bob"); + var admin = await H.connect("pub", "admin"); + H.relay.broadcastAll({ type: "projects_updated", marker: "list-1", projects: H.relay.getProjects(), projectCount: 99 }); + var forAlice = await alice.waitFor(function (m) { return m.marker === "list-1"; }); + var forBob = await bob.waitFor(function (m) { return m.marker === "list-1"; }); + var forAdmin = await admin.waitFor(function (m) { return m.marker === "list-1"; }); + assert.deepEqual(slugsOf(forAlice), ["pub"]); + assert.equal(forAlice.projectCount, 1); + assert.deepEqual(slugsOf(forBob), ["nov", "priv", "priv--wt", "pub"]); + assert.deepEqual(slugsOf(forAdmin), ["nov", "priv", "priv--wt", "pub"]); +}); + +test("renaming a project sends each client the project list its user may see", async function () { + var alice = await H.connect("pub", "alice"); + H.relay.setProjectTitle("pub", "Renamed Public"); + var info = await alice.waitFor(function (m) { return m.type === "info" && m.project === "Renamed Public"; }); + assert.deepEqual(slugsOf(info), ["pub"]); + assert.equal(info.projectCount, 1); +}); + +test("a connecting client is sent only the projects its user may see", async function () { + var alice = await H.connect("pub", "alice"); + var info = await alice.waitFor(function (m) { return m.type === "info"; }); + assert.deepEqual(slugsOf(info), ["pub"]); +}); + +test("schedules are listed and broadcast only for projects the user may see", async function () { + projectCtx("priv").importSchedule({ name: "schedule-in-private-project" }); + var alice = await H.connect("pub", "alice"); + var bob = await H.connect("pub", "bob"); + + projectCtx("pub").importSchedule({ name: "schedule-in-public-project" }); + var aliceUpdate = await alice.waitFor(function (m) { + return m.type === "loop_registry_updated" && m.records.some(function (r) { return r.name === "schedule-in-public-project"; }); + }); + var bobUpdate = await bob.waitFor(function (m) { + return m.type === "loop_registry_updated" && m.records.some(function (r) { return r.name === "schedule-in-public-project"; }); + }); + assert.equal(aliceUpdate.records.some(function (r) { return r.name === "schedule-in-private-project"; }), false); + assert.equal(bobUpdate.records.some(function (r) { return r.name === "schedule-in-private-project"; }), true); + + alice.send({ type: "hub_schedules_list" }); + var hub = await alice.waitFor(function (m) { return m.type === "hub_schedules"; }); + assert.deepEqual(hub.schedules.map(function (r) { return r.name; }).sort(), ["schedule-in-public-project"]); + bob.send({ type: "hub_schedules_list" }); + var bobHub = await bob.waitFor(function (m) { return m.type === "hub_schedules"; }); + assert.deepEqual(bobHub.schedules.map(function (r) { return r.name; }).sort(), ["schedule-in-private-project", "schedule-in-public-project"]); +}); + +// --- Sessions: rename, delete, search -------------------------------------- + +async function privateSessionOfBob(title) { + var s = projectCtx("pub").sm.createSessionRaw({ ownerId: H.people.bob.id, sessionVisibility: "private" }); + s.title = title; + return s; +} + +test("another user's private session cannot be renamed, deleted or searched", async function () { + var secret = await privateSessionOfBob("bobs-confidential-plan"); + var alice = await H.connect("pub", "alice"); + var sm = projectCtx("pub").sm; + + alice.send({ type: "rename_session", id: secret.localId, title: "hijacked" }); + await harness.settle(200); + assert.equal(sm.sessions.get(secret.localId).title, "bobs-confidential-plan"); + + alice.send({ type: "delete_session", id: secret.localId }); + await harness.settle(200); + assert.ok(sm.sessions.has(secret.localId), "the session was not deleted"); + + alice.send({ type: "search_sessions", query: "confidential" }); + var results = await alice.waitFor(function (m) { return m.type === "search_results"; }); + assert.deepEqual(results.results, [], "the title of a private session is not revealed"); + + alice.send({ type: "search_session_content", id: secret.localId, query: "x" }); + await harness.settle(200); + assert.equal(alice.inbox.filter(function (m) { return m.type === "search_content_results"; }).length, 0); + + alice.send({ type: "bulk_delete_sessions", sessionIds: [secret.localId] }); + await harness.settle(200); + assert.ok(sm.sessions.has(secret.localId), "a bulk delete leaves it too"); +}); + +test("a user can still rename, search and delete the sessions they may see", async function () { + var sm = projectCtx("pub").sm; + var mine = sm.createSessionRaw({ ownerId: H.people.alice.id }); + mine.title = "alices-own-session"; + var alice = await H.connect("pub", "alice"); + alice.send({ type: "rename_session", id: mine.localId, title: "alices-renamed" }); + await harness.settle(200); + assert.equal(sm.sessions.get(mine.localId).title, "alices-renamed"); + + alice.send({ type: "search_sessions", query: "alices-renamed" }); + var results = await alice.waitFor(function (m) { return m.type === "search_results" && m.query === "alices-renamed"; }); + assert.equal(results.results.length, 1); + + alice.send({ type: "delete_session", id: mine.localId }); + await harness.settle(200); + assert.equal(sm.sessions.has(mine.localId), false); +}); + +test("the owner of a session, and an administrator, can act on it", async function () { + var sm = projectCtx("pub").sm; + var s = await privateSessionOfBob("bobs-session-for-admin"); + var bob = await H.connect("pub", "bob"); + bob.send({ type: "rename_session", id: s.localId, title: "bobs-renamed" }); + await harness.settle(200); + assert.equal(sm.sessions.get(s.localId).title, "bobs-renamed"); +}); + +// --- File route ------------------------------------------------------------- + +test("the HTTP file route needs the fileBrowser permission", async function () { + var withPermission = await H.request("GET", "/p/pub/api/file?path=a.png", "alice"); + assert.equal(withPermission.status, 200); + assert.equal(withPermission.headers["content-type"], "image/png"); + assert.equal((await H.request("GET", "/p/pub/api/file?path=a.png", "carol")).status, 403); + assert.equal((await H.request("GET", "/p/pub/api/file?path=a.png", null)).status, 401); + assert.equal((await H.request("GET", "/p/pub/api/file?path=" + encodeURIComponent("../priv/secret.png"), "alice")).status, 403); + assert.equal((await H.request("GET", "/p/pub/api/file?path=" + encodeURIComponent("a.png\u0000.txt"), "alice")).status, 403); +}); + +test("the single-user owner reads files and the global CLAUDE.md as before", async function () { + var admin = await H.connect("pub", "admin"); + admin.send({ type: "fs_list", path: "." }); + var listing = await admin.waitFor(function (m) { return m.type === "fs_list_result"; }); + assert.deepEqual(listing.entries.map(function (e) { return e.name; }).sort(), ["a.png", "notes.txt"]); + admin.send({ type: "fs_read", path: "notes.txt" }); + var read = await admin.waitFor(function (m) { return m.type === "fs_read_result"; }); + assert.equal(read.content, "hello"); + admin.send({ type: "write_global_claude_md", content: "# owner" }); + assert.equal((await admin.waitFor(function (m) { return m.type === "write_global_claude_md_result"; })).ok, true); + admin.send({ type: "read_global_claude_md" }); + assert.equal((await admin.waitFor(function (m) { return m.type === "global_claude_md_result"; })).content, "# owner"); +}); + +// --- WebSocket origin ------------------------------------------------------- + +test("a same-port origin on a different host is refused; the daemon's own host is accepted", async function () { + var own = "http://127.0.0.1:" + H.port; + assert.ok((await H.connect("pub", "alice", { headers: { Origin: own } })).ok); + assert.equal((await H.connect("pub", "alice", { headers: { Origin: "http://evil.example:" + H.port } })).status, 403); + assert.equal((await H.connect("pub", "alice", { headers: { Origin: "http://localhost:" + H.port } })).status, 403); + assert.equal((await H.connect("pub", "alice", { headers: { Origin: "https://127.0.0.1:" + H.port } })).ok, true, "scheme is not compared when the daemon cannot know it"); +}); + +test("a client with no Origin (CLI, relay) is accepted; a login is still required", async function () { + assert.ok((await H.connect("pub", "alice")).ok); + assert.equal((await H.connect("pub", null)).status, 401); +}); + +test("an extension origin is refused unless the operator allow-lists it", async function () { + var origin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; + assert.equal((await H.connect("pub", "alice", { headers: { Origin: origin } })).status, 403); +}); diff --git a/test/access-harness.js b/test/access-harness.js new file mode 100644 index 00000000..448c9d67 --- /dev/null +++ b/test/access-harness.js @@ -0,0 +1,241 @@ +// A real Clagentic: Console server (lib/server.js) on a loopback port, with +// real users, real login cookies and real WebSocket clients, for the access +// tests. Nothing in lib/ is stubbed: only the project-access lookup the +// daemon would supply (onGetProjectAccess) is a table the test controls. +// +// The data directory is set before lib/ is first required, so a test file +// must require this module, then call start(), before it requires anything +// from lib/ itself. + +"use strict"; + +var fs = require("fs"); +var os = require("os"); +var path = require("path"); +var crypto = require("crypto"); +var http = require("http"); +var WebSocket = require("ws"); + +var COOKIE_NAME = "relay_auth_user"; +var TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +function userRecord(spec) { + return { + id: spec.id || crypto.randomUUID(), + username: spec.username, + email: null, + displayName: spec.username, + pinHash: "unused", + role: spec.role || "user", + mustChangePin: false, + createdAt: Date.now(), + linuxUser: spec.linuxUser || null, + permissions: spec.permissions || undefined, + profile: { name: spec.username, lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "abcd1234" }, + }; +} + +/** + * spec.users [{ key, username, role?, permissions?, linuxUser? }] + * spec.projects [{ slug, visibility?, ownerKey?, allowedKeys?, parent?, noRecord?, files? }] + * `parent` makes a worktree that takes its parent's access. + * `noRecord` leaves the access lookup without an answer for it. + * `files` maps a relative path to its text content. + * spec.osUsers run the server in os-users mode + * spec.multiUser false stores the daemon as single-user (default true) + * spec.serverOpts extra createServer options + * spec.beforeServer function called before lib/server is first required + */ +async function start(spec) { + var home = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-access-")); + process.env.CLAGENTIC_CONSOLE_HOME = home; + // The daemon user's own ~/.claude (global CLAUDE.md) lives under HOME: keep it inside the sandbox. + process.env.HOME = path.join(home, "userhome"); + fs.mkdirSync(path.join(process.env.HOME, ".claude"), { recursive: true }); + var consoleDir = path.join(home, "console"); + fs.mkdirSync(consoleDir, { recursive: true }); + + var people = {}; + var records = (spec.users || []).map(function (u) { + var rec = userRecord(u); + people[u.key] = rec; + return rec; + }); + fs.writeFileSync(path.join(consoleDir, "users.json"), JSON.stringify({ + multiUser: spec.multiUser !== false, setupCode: null, users: records, invites: [], smtp: null, + }), { mode: 0o600 }); + + var tokens = {}; + var cookies = {}; + Object.keys(people).forEach(function (key) { + var token = crypto.randomBytes(32).toString("hex"); + var now = Date.now(); + tokens[token] = { userId: people[key].id, issuedAt: now, expiresAt: now + TOKEN_TTL_MS }; + cookies[key] = COOKIE_NAME + "=" + token; + }); + fs.writeFileSync(path.join(consoleDir, "auth-tokens.json"), JSON.stringify(tokens), { mode: 0o600 }); + + var access = Object.create(null); + (spec.projects || []).forEach(function (p) { + if (p.parent || p.noRecord) return; + access[p.slug] = { + visibility: p.visibility, + ownerId: p.ownerKey ? people[p.ownerKey].id : null, + allowedUsers: (p.allowedKeys || []).map(function (k) { return people[k].id; }), + }; + }); + var parentOf = Object.create(null); + (spec.projects || []).forEach(function (p) { if (p.parent) parentOf[p.slug] = p.parent; }); + + // What daemon.js's onGetProjectAccess answers: a worktree carries its parent's access. + function lookup(slug) { + var key = parentOf[slug] || slug; + var a = access[key]; + if (!a) return { error: "Project not found" }; + var out = { slug: slug, allowedUsers: a.allowedUsers, ownerId: a.ownerId }; + if (a.visibility !== undefined) out.visibility = a.visibility; + return out; + } + + // Runs once the data directory is set and before lib/ is loaded, so a test + // can substitute a lib module the server is about to use. + if (typeof spec.beforeServer === "function") spec.beforeServer(); + var serverModule = require("../lib/server"); + var relay = serverModule.createServer(Object.assign({ + port: 0, + osUsers: !!spec.osUsers, + onGetProjectAccess: lookup, + }, spec.serverOpts || {})); + await new Promise(function (resolve) { relay.server.listen(0, "127.0.0.1", resolve); }); + var port = relay.server.address().port; + + var dirs = {}; + (spec.projects || []).forEach(function (p) { + var dir = path.join(home, "projects", p.slug); + fs.mkdirSync(dir, { recursive: true }); + Object.keys(p.files || {}).forEach(function (rel) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), p.files[rel]); + }); + dirs[p.slug] = dir; + var meta = p.parent ? { parentSlug: p.parent, branch: p.slug, accessible: true } : null; + relay.addProject(dir, p.slug, p.slug, null, p.ownerKey ? people[p.ownerKey].id : null, meta); + }); + + var sockets = []; + + /** + * Opens a WebSocket into a project as a user. Resolves { ok: true, ws, inbox, + * waitFor } or { ok: false, status } when the upgrade is refused. + * opts.headers adds or overrides request headers (Origin, Host, ...). + */ + function connect(slug, userKey, opts) { + opts = opts || {}; + return new Promise(function (resolve) { + var headers = Object.assign({}, userKey ? { Cookie: cookies[userKey] } : {}, opts.headers || {}); + var ws = new WebSocket("ws://127.0.0.1:" + port + "/p/" + slug + "/ws", { headers: headers }); + var inbox = []; + var waiters = []; + ws.on("message", function (raw) { + var msg = JSON.parse(raw.toString()); + inbox.push(msg); + waiters = waiters.filter(function (w) { + if (!w.pred(msg)) return true; + clearTimeout(w.timer); + w.resolve(msg); + return false; + }); + }); + function waitFor(pred, ms) { + var seen = inbox.filter(pred); + if (seen.length) return Promise.resolve(seen[0]); + return new Promise(function (res, rej) { + var waiter = { pred: pred, resolve: res }; + waiter.timer = setTimeout(function () { + waiters = waiters.filter(function (w) { return w !== waiter; }); + rej(new Error("timed out waiting for a message")); + }, ms || 4000); + waiters.push(waiter); + }); + } + ws.on("open", function () { + sockets.push(ws); + resolve({ ok: true, ws: ws, inbox: inbox, waitFor: waitFor, send: function (m) { ws.send(JSON.stringify(m)); } }); + }); + ws.on("unexpected-response", function (req, res) { + res.resume(); + resolve({ ok: false, status: res.statusCode }); + }); + ws.on("error", function (e) { + resolve({ ok: false, status: 0, error: e.message }); + }); + }); + } + + function request(method, urlPath, userKey, body, extraHeaders) { + return new Promise(function (resolve, reject) { + var payload = body === undefined ? null : JSON.stringify(body); + var headers = Object.assign({}, userKey ? { Cookie: cookies[userKey] } : {}, extraHeaders || {}); + if (payload) { headers["Content-Type"] = "application/json"; headers["Content-Length"] = Buffer.byteLength(payload); } + var req = http.request({ host: "127.0.0.1", port: port, method: method, path: urlPath, headers: headers }, function (res) { + var chunks = []; + res.on("data", function (c) { chunks.push(c); }); + res.on("end", function () { resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") }); }); + }); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); + } + + // Teardown faults fail the test that owns the server: the sockets and the + // projects are all released first, then the first error seen is thrown. + async function stop() { + var failures = []; + sockets.forEach(function (ws) { + try { ws.terminate(); } catch (e) { failures.push(e); } + }); + try { + await relay.destroyAll(); + } catch (e) { + failures.push(e); + } + await new Promise(function (resolve) { relay.server.close(function () { resolve(); }); }); + if (typeof relay.server.closeAllConnections === "function") relay.server.closeAllConnections(); + // An adapter may still be writing under HOME as the server shuts down: + // give those writes time to land, and retry the removal while one races it. + await settle(300); + var removed = false; + for (var attempt = 0; attempt < 5 && !removed; attempt++) { + try { + fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + removed = true; + } catch (e) { + if (e.code !== "ENOTEMPTY" || attempt === 4) failures.push(e); + if (e.code !== "ENOTEMPTY") break; + await settle(300); + } + } + if (failures.length) throw failures[0]; + } + + return { + home: home, + port: port, + relay: relay, + people: people, + dirs: dirs, + cookies: cookies, + connect: connect, + request: request, + stop: stop, + }; +} + +// Resolves once `ms` has passed with nothing else to wait for: used to show +// that a message was NOT delivered. +function settle(ms) { + return new Promise(function (resolve) { setTimeout(resolve, ms || 150); }); +} + +module.exports = { start: start, settle: settle }; diff --git a/test/authentication-precedes-principal.test.js b/test/authentication-precedes-principal.test.js new file mode 100644 index 00000000..d8b21da6 --- /dev/null +++ b/test/authentication-precedes-principal.test.js @@ -0,0 +1,121 @@ +// Authentication is a separate, earlier input than the principal: "no user" is +// the implicit single-user owner only for a request or connection that proved +// it passed the login gate. This drives a real server with the login gate +// stood in for (its answer is a variable the test sets, and it never names a +// user), once per daemon mode. +// +// Swept: every route and socket that resolves a principal (palette, /api/file +// through the /p/ gate, /p/, root redirect, /info, WebSocket +// upgrade) plus the local MCP bridge, which carries no login by design. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var harness = require("./access-harness"); + +var H = null; +var gate = { authed: false, multiUser: false }; + +function projectCtx(slug) { + var found = null; + H.relay.forEachProject(function (ctx, s) { if (s === slug) found = ctx; }); + return found; +} + +test.before(async function () { + H = await harness.start({ + multiUser: false, + users: [], + projects: [ + { slug: "mine", files: { "a.png": "png" } }, + { slug: "other" }, + ], + serverOpts: { isMultiUser: function () { return gate.multiUser; } }, + beforeServer: function () { + var serverAuth = require("../lib/server-auth"); + var attach = serverAuth.attachAuth; + serverAuth.attachAuth = function (deps) { + return Object.assign({}, attach(deps), { + getMultiUserFromReq: function () { return null; }, + isRequestAuthed: function () { return gate.authed; }, + }); + }; + }, + }); + var s = projectCtx("other").sm.createSessionRaw({ ownerId: "a-former-user", sessionVisibility: "private" }); + s.title = "zebrafish-vault-notes"; +}); + +test.after(async function () { + if (H) await H.stop(); +}); + +function withGate(state, fn) { + return async function () { + var before = Object.assign({}, gate); + Object.assign(gate, state); + try { + await fn(); + } finally { + Object.assign(gate, before); + } + }; +} + +[false, true].forEach(function (multiUser) { + var label = multiUser ? "multi-user" : "single-user"; + + test(label + ": an unauthenticated palette request is refused and reveals no session", withGate({ authed: false, multiUser: multiUser }, async function () { + var search = await H.request("GET", "/api/palette/search?q=zebrafish", null); + assert.equal(search.status, 401); + assert.doesNotMatch(search.body, /zebrafish/); + var recent = await H.request("GET", "/api/palette/search", null); + assert.equal(recent.status, 401); + assert.doesNotMatch(recent.body, /zebrafish/); + })); + + test(label + ": unauthenticated project routes, root and info serve nothing of the owner's", withGate({ authed: false, multiUser: multiUser }, async function () { + var file = await H.request("GET", "/p/mine/api/file?path=a.png", null); + assert.equal(file.status, 401, "/api/file"); + var page = await H.request("GET", "/p/mine/", null); + assert.equal(page.status, 200); + assert.equal(page.headers["set-cookie"], undefined, "no project is selected for a stranger"); + assert.equal(page.headers.location, undefined); + var root = await H.request("GET", "/", null); + assert.equal(root.status, 200, "the root shows the login page, never a project redirect"); + assert.equal(root.headers.location, undefined); + var info = await H.request("GET", "/info", null); + assert.equal(info.status, 401); + assert.doesNotMatch(info.body, /"slug"/); + })); + + test(label + ": an unauthenticated WebSocket upgrade is refused", withGate({ authed: false, multiUser: multiUser }, async function () { + assert.equal((await H.connect("mine", null)).status, 401); + assert.equal((await H.connect("other", null)).status, 401); + })); + + test(label + ": the local MCP bridge needs no login", withGate({ authed: false, multiUser: multiUser }, async function () { + var bridge = await H.request("POST", "/p/mine/api/mcp-bridge", null, { action: "no-such-action" }); + assert.equal(bridge.status, 400, "the request reached the bridge handler"); + })); +}); + +test("single-user: an authenticated owner is served the palette, the project routes and the socket", withGate({ authed: true, multiUser: false }, async function () { + var search = await H.request("GET", "/api/palette/search?q=zebrafish", null); + assert.equal(search.status, 200); + assert.deepEqual(JSON.parse(search.body).results.map(function (r) { return r.sessionTitle; }), ["zebrafish-vault-notes"], search.body); + assert.equal((await H.request("GET", "/api/palette/search", null)).status, 200); + assert.equal((await H.request("GET", "/p/mine/api/file?path=a.png", null)).status, 200); + var root = await H.request("GET", "/", null); + assert.equal(root.status, 302); + assert.equal((await H.request("GET", "/info", null)).status, 200); + var c = await H.connect("mine", null); + assert.ok(c.ok, "WebSocket upgrade"); +})); + +test("multi-user: a login that names no user is nobody, however the gate answered", withGate({ authed: true, multiUser: true }, async function () { + assert.equal((await H.request("GET", "/api/palette/search", null)).status, 401); + assert.equal((await H.request("GET", "/p/mine/api/file?path=a.png", null)).status, 302); + assert.equal((await H.connect("mine", null)).status, 401); +})); diff --git a/test/client-supplied-keys.test.js b/test/client-supplied-keys.test.js new file mode 100644 index 00000000..6b7dd0b0 --- /dev/null +++ b/test/client-supplied-keys.test.js @@ -0,0 +1,163 @@ +// Names and ids that arrive from a client are never used as keys on a plain +// object or handed to a lookup unchecked: a hostile vendor, tab id or request +// id is refused, the server keeps running, and no prototype is touched. +// Also: only the socket an extension command was sent to may answer it. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var harness = require("./access-harness"); + +var HOSTILE = ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf", "isPrototypeOf", "__defineGetter__", "prototype", "", 7, null, true, {}, ["claude"], { toString: 1 }]; + +var H = null; + +function projectCtx(slug) { + var found = null; + H.relay.forEachProject(function (ctx, s) { if (s === slug) found = ctx; }); + return found; +} + +function assertPrototypesClean() { + assert.equal({}.polluted, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(Object.prototype, "models"), false); + assert.equal(Object.getPrototypeOf({}), Object.prototype); +} + +test.before(async function () { + H = await harness.start({ + users: [{ key: "alice", username: "alice" }, { key: "bob", username: "bob" }], + projects: [{ slug: "pub", visibility: "public", ownerKey: "bob" }], + }); +}); + +test.after(async function () { + if (H) await H.stop(); +}); + +test("a hostile vendor in get_vendor_models gets an empty answer and changes nothing", async function () { + var c = await H.connect("pub", "alice"); + var sm = projectCtx("pub").sm; + for (var i = 0; i < HOSTILE.length; i++) { + c.send({ type: "get_vendor_models", vendor: HOSTILE[i] }); + var info = await c.waitFor(function (m) { return m.type === "model_info" && Array.isArray(m.models) && m.models.length === 0 && m.__seen !== true; }); + assert.deepEqual(info.models, [], "vendor " + JSON.stringify(HOSTILE[i])); + info.__seen = true; + } + assert.equal(Object.getPrototypeOf(sm.modelsByVendor || {}) === Object.prototype || Object.getPrototypeOf(sm.modelsByVendor) === null, true); + assert.equal(Object.keys(sm.modelsByVendor || {}).filter(function (k) { return HOSTILE.indexOf(k) !== -1; }).length, 0); + assertPrototypesClean(); + assert.ok((await H.connect("pub", "alice")).ok, "the server is still serving"); +}); + +test("a hostile vendor is not bound to a session by new_session, set_vendor or a message", async function () { + var c = await H.connect("pub", "alice"); + var sm = projectCtx("pub").sm; + for (var i = 0; i < HOSTILE.length; i++) { + if (typeof HOSTILE[i] === "string" && HOSTILE[i] !== "") { + c.send({ type: "new_session", vendor: HOSTILE[i] }); + await harness.settle(80); + } + } + sm.sessions.forEach(function (s) { + assert.ok(s.vendor === null || s.vendor === undefined || s.vendor === "claude" || s.vendor === "codex", "session vendor " + JSON.stringify(s.vendor)); + }); + c.send({ type: "new_session" }); + await harness.settle(100); + for (var j = 0; j < HOSTILE.length; j++) { + c.send({ type: "set_vendor", vendor: HOSTILE[j] }); + await harness.settle(40); + } + sm.sessions.forEach(function (s) { + assert.ok(s.vendor === null || s.vendor === undefined || s.vendor === "claude" || s.vendor === "codex", "session vendor " + JSON.stringify(s.vendor)); + }); + assertPrototypesClean(); + assert.ok((await H.connect("pub", "alice")).ok); +}); + +test("a known vendor is still accepted", async function () { + var c = await H.connect("pub", "alice"); + c.send({ type: "new_session", vendor: "codex" }); + await harness.settle(150); + var sm = projectCtx("pub").sm; + var vendors = []; + sm.sessions.forEach(function (s) { vendors.push(s.vendor); }); + assert.ok(vendors.indexOf("codex") !== -1); +}); + +// The handlers are driven directly (with the project's real session manager and +// prompt registry): the browser tools only exist when an agent CLI is installed. +function messageHandlers(extra) { + var { attachUserMessage } = require("../lib/project-user-message"); + return attachUserMessage(Object.assign({ sm: projectCtx("pub").sm, opts: {}, clients: new Set() }, extra)); +} + +test("a browser tab list keyed by hostile ids does not reach the prototype", function () { + var browserState = { _browserTabList: Object.create(null), _extensionWs: null }; + var list = browserState._browserTabList; + var handlers = messageHandlers({ browserState: browserState }); + var ws = { readyState: 1 }; + handlers.handleUserMessage(ws, { type: "browser_tab_list", tabs: [ + { id: "__proto__", url: "https://polluted.example", title: "polluted" }, + { id: "constructor", url: "u" }, + { id: 7, url: "https://seven.example", title: "seven" }, + null, 5, "tab", { url: "no id" }, { id: NaN }, + ] }); + assert.equal(browserState._extensionWs, ws); + assert.deepEqual(Object.keys(list), ["7"], "only a tab with a numeric id is kept"); + assert.equal(Object.getPrototypeOf(list), null); + assert.equal(browserState._browserTabList, list, "the list is updated in place, so every holder sees it"); + assertPrototypesClean(); + + handlers.handleUserMessage(ws, { type: "browser_tab_list", tabs: "not a list" }); + assert.deepEqual(Object.keys(list), [], "a new report replaces the old one"); + handlers.handleUserMessage(ws, { type: "browser_tab_list" }); +}); + +test("only the socket an extension command was sent to can answer it", async function () { + var { createExtensionCommands } = require("../lib/extension-commands"); + var sm = projectCtx("pub").sm; + var sent = []; + var commands = createExtensionCommands({ + getSessionManager: function () { return sm; }, + sendTo: function (ws, msg) { sent.push({ ws: ws, msg: msg }); }, + }); + var extension = { readyState: 1 }; + var forger = { readyState: 1 }; + var handlers = messageHandlers({ mayAnswerExtensionCommand: commands.mayAnswer }); + + var settled = false; + var call = commands.send(extension, "tab_close", { tabId: 1 }, 10000).then(function (r) { settled = true; return r; }); + assert.equal(sent.length, 1); + assert.equal(sent[0].ws, extension, "the command went to the extension socket"); + var requestId = sent[0].msg.requestId; + assert.equal(commands.mayAnswer(requestId, extension), true); + assert.equal(commands.mayAnswer(requestId, forger), false); + + handlers.handleUserMessage(forger, { type: "extension_result", requestId: requestId, result: { forged: true } }); + await harness.settle(100); + assert.equal(settled, false, "another client's extension_result does not settle the command"); + + handlers.handleUserMessage(extension, { type: "extension_result", requestId: requestId, result: { ok: true } }); + assert.deepEqual(await call, { ok: true }); + assert.equal(commands.mayAnswer(requestId, extension), false, "a settled command is forgotten"); + HOSTILE.forEach(function (id) { assert.equal(commands.mayAnswer(id, extension), false); }); +}); + +test("with no binding to the sockets commands went to, nothing may answer one", function () { + var handlers = messageHandlers({}); + assert.doesNotThrow(function () { + handlers.handleUserMessage({ readyState: 1 }, { type: "extension_result", requestId: "anything", result: {} }); + }); +}); + +test("an extension_result for a request that was never sent is ignored", async function () { + var ext = await H.connect("pub", "alice"); + for (var i = 0; i < HOSTILE.length; i++) { + ext.send({ type: "extension_result", requestId: HOSTILE[i], result: {} }); + } + await harness.settle(150); + assertPrototypesClean(); + assert.ok((await H.connect("pub", "alice")).ok); +}); diff --git a/test/custom-emoji-audit-log-lr-fc71.test.js b/test/custom-emoji-audit-log-lr-fc71.test.js index 1106ddb2..a5c9ade3 100644 --- a/test/custom-emoji-audit-log-lr-fc71.test.js +++ b/test/custom-emoji-audit-log-lr-fc71.test.js @@ -37,7 +37,7 @@ function makeTempHome() { // pattern in session-lifecycle-lr-e0de.test.js. function freshAudit(tmpHome) { ["../lib/config", "../lib/audit"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/exit-listener-leak-lr-daca.test.js b/test/exit-listener-leak-lr-daca.test.js index f9a7e908..79c0d771 100644 --- a/test/exit-listener-leak-lr-daca.test.js +++ b/test/exit-listener-leak-lr-daca.test.js @@ -27,7 +27,7 @@ function makeTempHome() { // isolation pattern used in session-lifecycle-lr-e0de.test.js. function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/hostile-keys-stores.test.js b/test/hostile-keys-stores.test.js new file mode 100644 index 00000000..a969ddfe --- /dev/null +++ b/test/hostile-keys-stores.test.js @@ -0,0 +1,190 @@ +// Tables keyed by names and ids that come from outside (a client, a worker, an +// MCP server, a config file) have no prototype to answer for "constructor" or +// "__proto__", and the MCP bridge accepts a result only from the socket the +// call went to. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var os = require("os"); +var path = require("path"); + +var SANDBOX = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-hostile-keys-")); +process.env.CLAGENTIC_CONSOLE_HOME = path.join(SANDBOX, "data"); +process.env.HOME = path.join(SANDBOX, "home"); + +var { createSessionManager } = require("../lib/sessions"); +var { promptsFor } = require("../lib/prompt-registry"); +var { sanitizeAllowedTools } = require("../lib/utils"); +var { attachMcp } = require("../lib/project-mcp"); +var { createLocalMcp } = require("../lib/mcp-local"); + +test.after(function () { fs.rmSync(SANDBOX, { recursive: true, force: true }); }); + +var NAMES = ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf", "__defineGetter__"]; + +function isMap(obj) { + return Object.getPrototypeOf(obj) === null; +} + +function newManager() { + return createSessionManager({ cwd: path.join(SANDBOX, "project"), send: function () {}, sendTo: function () {}, sendEach: function () {} }); +} + +// --- session stores --------------------------------------------------------- + +test("a session's prompt stores, grants and the request index have no prototype", function () { + var sm = newManager(); + var created = sm.createSessionRaw({}); + var resumed = sm.resumeSession("hostile-keys-resume", { history: [], title: "r" }, null); + [created, resumed].forEach(function (s) { + assert.ok(isMap(s.pendingPermissions), "pendingPermissions"); + assert.ok(isMap(s.pendingAskUser), "pendingAskUser"); + assert.ok(isMap(s.allowedTools), "allowedTools"); + }); + assert.ok(isMap(sm.permissionRequestIndex), "the request index"); +}); + +test("a registry that builds its own stores builds them without a prototype", function () { + var sm = { sessions: new Map(), sendAndRecord: function () {}, saveSessionFile: function () {} }; + var s = { localId: 1, history: [] }; + sm.sessions.set(1, s); + var prompts = promptsFor(sm); + assert.ok(isMap(sm.permissionRequestIndex)); + prompts.taskStarted(s, "task"); + prompts.noteSubagentTool(s, "tool", "task"); + assert.ok(isMap(s.activeTaskToolIds)); + assert.ok(isMap(s.subagentToolOwners)); + prompts.grant(s, "Bash", { command: "ls" }); + assert.ok(isMap(s.allowedTools)); +}); + +test("looking up a hostile name finds nothing, in the stores and in the index", function () { + var sm = newManager(); + var s = sm.createSessionRaw({}); + var prompts = promptsFor(sm); + NAMES.forEach(function (name) { + assert.equal(s.pendingPermissions[name], undefined, name); + assert.equal(sm.permissionRequestIndex[name], undefined, name); + assert.equal(prompts.lookup(name), null, name); + assert.deepEqual(prompts.respond(name, { decision: "allow" }), { status: "stale" }, name); + }); +}); + +test("a persisted grant map keeps only true-valued tool names and has no prototype", function () { + var parsed = JSON.parse('{"Bash": true, "__proto__": true, "constructor": true, "Edit": "yes"}'); + var clean = sanitizeAllowedTools(parsed); + assert.ok(isMap(clean)); + assert.deepEqual(Object.keys(clean).sort(), ["Bash", "__proto__", "constructor"]); + assert.equal(Object.getPrototypeOf(clean), null, "the __proto__ entry is data, not a prototype"); + assert.equal({}.Bash, undefined); + assert.ok(isMap(sanitizeAllowedTools(null))); +}); + +// --- MCP bridge ------------------------------------------------------------- + +function bridge(extensionSocket) { + var sent = []; + var mcp = attachMcp({ + send: function () {}, + sendTo: function (ws, msg) { sent.push({ ws: ws, msg: msg }); }, + slug: "p", + getExtensionWs: function () { return extensionSocket; }, + getEnabledMcpServers: function () { return ["srv", "__proto__"]; }, + setEnabledMcpServers: function () {}, + localMcp: null, + }); + return { mcp: mcp, sent: sent }; +} + +async function offerServer(b, ws) { + b.mcp.handleMcpMessage(ws, { type: "mcp_servers_available", hostConnected: true, servers: [ + { name: "srv", tools: [{ name: "t", description: "d", inputSchema: { properties: {} } }] }, + ] }); +} + +test("the MCP bridge's server and call tables have no prototype", async function () { + var ext = { readyState: 1 }; + var b = bridge(ext); + await offerServer(b, ext); + assert.ok(isMap(b.mcp.getMcpServers())); + assert.ok(b.mcp.getMcpServers().srv, "the offered server is there"); + assert.equal(b.mcp.getMcpServers().constructor, undefined, "and a hostile name is not"); + assert.equal(b.mcp.getMcpServers().__proto__, undefined); +}); + +test("hostile mcp_servers_available payloads are skipped, not thrown on", function () { + var ext = { readyState: 1 }; + var b = bridge(ext); + var payloads = [ + { servers: null }, { servers: 5 }, { servers: { length: 3 } }, { servers: "text" }, + { servers: [null, 7, "x", {}, { name: 5 }, { name: { toString: 1 } }, { name: "__proto__", tools: "no" }, { name: "constructor", tools: [1] }] }, + ]; + payloads.forEach(function (p) { + assert.doesNotThrow(function () { + b.mcp.handleMcpMessage(ext, Object.assign({ type: "mcp_servers_available" }, p)); + }, JSON.stringify(p)); + }); + assert.equal({}.polluted, undefined); +}); + +test("a tool result is accepted only from the socket the call went to", async function () { + var ext = { readyState: 1 }; + var other = { readyState: 1 }; + var b = bridge(ext); + await offerServer(b, ext); + var tool = b.mcp.getMcpServers().srv.instance._registeredTools.t; + var settled = null; + var call = tool.handler({}).then(function (r) { settled = { ok: r }; }, function (e) { settled = { err: e.message }; }); + var request = b.sent.filter(function (s) { return s.msg.type === "mcp_tool_call"; })[0]; + assert.equal(request.ws, ext, "the call went to the extension socket"); + var callId = request.msg.callId; + + b.mcp.handleMcpMessage(other, { type: "mcp_tool_result", callId: callId, result: { content: [{ type: "text", text: "forged" }] } }); + b.mcp.handleMcpMessage(other, { type: "mcp_tool_error", callId: callId, error: "forged failure" }); + NAMES.concat([5, null, {}, [callId], { toString: 1 }]).forEach(function (id) { + assert.doesNotThrow(function () { + b.mcp.handleMcpMessage(ext, { type: "mcp_tool_result", callId: id, result: {} }); + b.mcp.handleMcpMessage(ext, { type: "mcp_tool_error", callId: id, error: "x" }); + }, JSON.stringify(id)); + }); + await new Promise(function (resolve) { setImmediate(resolve); }); + assert.equal(settled, null, "nothing the wrong socket or a hostile id sent settled the call"); + + b.mcp.handleMcpMessage(ext, { type: "mcp_tool_result", callId: callId, result: { content: [{ type: "text", text: "real" }] } }); + await call; + assert.equal(settled.ok.content[0].text, "real"); +}); + +test("a hostile name toggled on or off changes nothing", function () { + var ext = { readyState: 1 }; + var enabled = []; + var mcp = attachMcp({ + send: function () {}, sendTo: function () {}, slug: "p", + getExtensionWs: function () { return ext; }, + getEnabledMcpServers: function () { return enabled; }, + setEnabledMcpServers: function (list) { enabled = list; }, + }); + [5, null, {}, [], { toString: 1 }, ""].forEach(function (name) { + assert.doesNotThrow(function () { mcp.handleMcpMessage(ext, { type: "mcp_toggle_server", name: name, enabled: true }); }); + }); + assert.deepEqual(enabled, []); + mcp.handleMcpMessage(ext, { type: "mcp_toggle_server", name: "srv", enabled: true }); + assert.deepEqual(enabled, ["srv"]); +}); + +// --- local MCP process manager ------------------------------------------------ + +test("removing or calling a server named like an inherited property does not throw", async function () { + var local = createLocalMcp(); + NAMES.forEach(function (name) { + assert.doesNotThrow(function () { local.removeServer(name); }, name); + }); + for (var i = 0; i < NAMES.length; i++) { + await assert.rejects(local.callTool(NAMES[i], "t", {}), /not running/); + } + assert.equal({}.polluted, undefined); + local.shutdown(); +}); diff --git a/test/hub-recent-sessions-access-filter-lr-c4da07.test.js b/test/hub-recent-sessions-access-filter-lr-c4da07.test.js index bff9f4cf..84451b36 100644 --- a/test/hub-recent-sessions-access-filter-lr-c4da07.test.js +++ b/test/hub-recent-sessions-access-filter-lr-c4da07.test.js @@ -70,8 +70,8 @@ var PROJECT_LOOP_JS = readMod("lib/project-loop.js"); test("lib/project-loop.js: hub_recent_sessions_list threads the connecting client's userId into getAllProjectSessions", function () { assert.match( PROJECT_LOOP_JS, - /getAllProjectSessions\s*\(\s*true\s*,\s*hubUserId\s*\)/, - "expected getAllProjectSessions(true, hubUserId) — userId threaded from the connecting ws" + /getAllProjectSessions\s*\(\s*true\s*,\s*hubUserId\s*,\s*authOf\(ws\)\s*\)/, + "expected getAllProjectSessions(true, hubUserId, authOf(ws)) — userId and login proof threaded from the connecting ws" ); assert.match( PROJECT_LOOP_JS, @@ -92,7 +92,7 @@ function makeTempHome() { function loadRealUsers(tmpHome) { ["../lib/config", "../lib/users", "../lib/users-auth", "../lib/users-permissions", "../lib/users-preferences", "../lib/store"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -313,7 +313,7 @@ function makeEngine(cwd, getAllProjectSessionsStub) { var tmpHome = makeTempHomeLoop(); ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop", "../lib/loop-handoff"] .forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/loop-resume-ended-session-lr-fd38ac.test.js b/test/loop-resume-ended-session-lr-fd38ac.test.js index 6a820292..397d0c91 100644 --- a/test/loop-resume-ended-session-lr-fd38ac.test.js +++ b/test/loop-resume-ended-session-lr-fd38ac.test.js @@ -89,7 +89,7 @@ function makeEngine(cwd) { ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop", "../lib/loop-handoff"] .forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; diff --git a/test/lr-1bdb-backend-correctness.test.js b/test/lr-1bdb-backend-correctness.test.js index 76330557..cb20adda 100644 --- a/test/lr-1bdb-backend-correctness.test.js +++ b/test/lr-1bdb-backend-correctness.test.js @@ -249,7 +249,7 @@ test("C: mkdirSync-based targetDir reservation lets only one of two concurrent c function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/memory-shed-live-child-reclaim-lr-f36626.test.js b/test/memory-shed-live-child-reclaim-lr-f36626.test.js index ef2de485..a69d6bed 100644 --- a/test/memory-shed-live-child-reclaim-lr-f36626.test.js +++ b/test/memory-shed-live-child-reclaim-lr-f36626.test.js @@ -39,7 +39,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, extraOpts) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/memory-shed-lr-5e70.test.js b/test/memory-shed-lr-5e70.test.js index 7d0f85cb..e725b44b 100644 --- a/test/memory-shed-lr-5e70.test.js +++ b/test/memory-shed-lr-5e70.test.js @@ -27,7 +27,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, extraOpts) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/palette-search-unloaded-session-lr-a3e175.test.js b/test/palette-search-unloaded-session-lr-a3e175.test.js index e5ca8e1d..4e05c3d8 100644 --- a/test/palette-search-unloaded-session-lr-a3e175.test.js +++ b/test/palette-search-unloaded-session-lr-a3e175.test.js @@ -31,7 +31,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/permission-grant-persist-lr-8b2e.test.js b/test/permission-grant-persist-lr-8b2e.test.js index c5c99bad..9d3525cf 100644 --- a/test/permission-grant-persist-lr-8b2e.test.js +++ b/test/permission-grant-persist-lr-8b2e.test.js @@ -30,6 +30,18 @@ var os = require("os"); var { createSDKBridge } = require("../lib/sdk-bridge"); +// A session's grant map has no prototype (its keys are tool names). That is +// asserted here, then the own keys are copied with defineProperty so an own +// "__proto__" key stays visible to deepEqual (assignment would swallow it). +function plain(map) { + assert.equal(Object.getPrototypeOf(map), null, "the grant map has no prototype"); + var copy = {}; + Object.getOwnPropertyNames(map).forEach(function (key) { + Object.defineProperty(copy, key, { value: map[key], enumerable: true, writable: true, configurable: true }); + }); + return copy; +} + function makeTempHome() { return fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-test-lr-8b2e-")); } @@ -37,7 +49,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { // Bust require cache so a fresh instance picks up the temp CLAGENTIC_HOME. ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -108,7 +120,7 @@ test("lr-8b2e: grant survives loadSessions() rehydration — auto-approve HITS f if (s.cliSessionId === "sess-lr-8b2e-grant") rebuilt = s; }); assert.ok(rebuilt, "rebuilt session should be found after rehydration"); - assert.deepEqual(rebuilt.allowedTools, { Write: true }, + assert.deepEqual(plain(rebuilt.allowedTools), { Write: true }, "allowedTools must be hydrated from the persisted meta record"); // Assert the auto-approve early-return at lib/sdk-bridge.js:691 HITS for @@ -190,7 +202,7 @@ test("lr-8b2e: saveSessionFile omits allowedTools entirely when no grant was eve if (s.cliSessionId === "sess-lr-8b2e-empty") rebuilt = s; }); assert.ok(rebuilt); - assert.deepEqual(rebuilt.allowedTools, {}, "no-grant sessions must rehydrate to an empty allowedTools object"); + assert.deepEqual(plain(rebuilt.allowedTools), {}, "no-grant sessions must rehydrate to an empty allowedTools object"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } @@ -210,7 +222,7 @@ test("lr-8b2e: resumeSession() hydrates allowedTools from opts when rebuilding a allowedTools: { Bash: true }, }, null); - assert.deepEqual(resumed.allowedTools, { Bash: true }, + assert.deepEqual(plain(resumed.allowedTools), { Bash: true }, "resumeSession must hydrate allowedTools from opts, not default to {}"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); @@ -226,7 +238,7 @@ test("lr-8b2e: resumeSession() defaults to empty allowedTools when opts carries title: "Resumed", }, null); - assert.deepEqual(resumed.allowedTools, {}, + assert.deepEqual(plain(resumed.allowedTools), {}, "resumeSession must default to {} when no persisted grant exists — never invents an approval"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); @@ -258,7 +270,7 @@ test("lr-8b2e hardening: a legit persisted grant still hydrates and auto-approve if (s.cliSessionId === "sess-lr-8b2e-hardening-legit") rebuilt = s; }); assert.ok(rebuilt, "rebuilt session should be found after rehydration"); - assert.deepEqual(rebuilt.allowedTools, { Write: true }, + assert.deepEqual(plain(rebuilt.allowedTools), { Write: true }, "a legitimately-granted tool must still round-trip exactly as before hardening"); var bridge = makeBridge(sm2); @@ -301,7 +313,7 @@ test("lr-8b2e hardening: a persisted allowedTools with a non-boolean value is re if (s.cliSessionId === "sess-lr-8b2e-hardening-nonbool") rebuilt = s; }); assert.ok(rebuilt, "rebuilt session should be found after loading the crafted meta record"); - assert.deepEqual(rebuilt.allowedTools, {}, + assert.deepEqual(plain(rebuilt.allowedTools), {}, "non-boolean-value entries must be dropped, not hydrated as truthy grants"); // Use "Edit" (not "Bash") so the assertion isolates the allowedTools @@ -344,7 +356,7 @@ test("lr-8b2e hardening: a non-object allowedTools value hydrates as empty and d if (s.cliSessionId === "sess-lr-8b2e-hardening-nonobject") rebuilt = s; }); assert.ok(rebuilt, "rebuilt session should be found after loading the crafted meta record"); - assert.deepEqual(rebuilt.allowedTools, {}, + assert.deepEqual(plain(rebuilt.allowedTools), {}, "a non-object allowedTools value must hydrate as empty, never throw"); }); } finally { @@ -354,11 +366,11 @@ test("lr-8b2e hardening: a non-object allowedTools value hydrates as empty and d test("lr-8b2e hardening: sanitizeAllowedTools utility rejects arrays, null, and mixed-shape entries directly", function () { var utils = require("../lib/utils"); - assert.deepEqual(utils.sanitizeAllowedTools(null), {}); - assert.deepEqual(utils.sanitizeAllowedTools(undefined), {}); - assert.deepEqual(utils.sanitizeAllowedTools(["Bash"]), {}); - assert.deepEqual(utils.sanitizeAllowedTools("Bash"), {}); - assert.deepEqual(utils.sanitizeAllowedTools({ Bash: true, Edit: false, Write: "true", Read: 1 }), { Bash: true }); + assert.deepEqual(plain(utils.sanitizeAllowedTools(null)), {}); + assert.deepEqual(plain(utils.sanitizeAllowedTools(undefined)), {}); + assert.deepEqual(plain(utils.sanitizeAllowedTools(["Bash"])), {}); + assert.deepEqual(plain(utils.sanitizeAllowedTools("Bash")), {}); + assert.deepEqual(plain(utils.sanitizeAllowedTools({ Bash: true, Edit: false, Write: "true", Read: 1 })), { Bash: true }); }); test("lr-8b2e hardening: resumeSession() sanitizes a malformed allowedTools passed via opts", function () { @@ -371,7 +383,7 @@ test("lr-8b2e hardening: resumeSession() sanitizes a malformed allowedTools pass allowedTools: { Bash: true, Edit: "yes" }, }, null); - assert.deepEqual(resumed.allowedTools, { Bash: true }, + assert.deepEqual(plain(resumed.allowedTools), { Bash: true }, "resumeSession must sanitize opts.allowedTools defense-in-depth, dropping non-boolean entries"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); diff --git a/test/permission-grant-skill-key-lr-f969dc.test.js b/test/permission-grant-skill-key-lr-f969dc.test.js index 323977f6..68f8aa3f 100644 --- a/test/permission-grant-skill-key-lr-f969dc.test.js +++ b/test/permission-grant-skill-key-lr-f969dc.test.js @@ -34,6 +34,18 @@ var os = require("os"); var { createSDKBridge } = require("../lib/sdk-bridge"); var utils = require("../lib/utils"); +// A session's grant map has no prototype (its keys are tool names). That is +// asserted here, then the own keys are copied with defineProperty so an own +// "__proto__" key stays visible to deepEqual (assignment would swallow it). +function plain(map) { + assert.equal(Object.getPrototypeOf(map), null, "the grant map has no prototype"); + var copy = {}; + Object.getOwnPropertyNames(map).forEach(function (key) { + Object.defineProperty(copy, key, { value: map[key], enumerable: true, writable: true, configurable: true }); + }); + return copy; +} + function makeTempHome() { return fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-test-lr-f969dc-")); } @@ -41,7 +53,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { // Bust require cache so a fresh instance picks up the temp CLAGENTIC_HOME. ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -165,7 +177,7 @@ test("lr-f969dc: a Skill grant survives loadSessions() rehydration and stays sco if (s.cliSessionId === "sess-lr-f969dc-rehydrate") rebuilt = s; }); assert.ok(rebuilt, "rebuilt session should be found after rehydration"); - assert.deepEqual(rebuilt.allowedTools, { "Skill:claude-api": true }, + assert.deepEqual(plain(rebuilt.allowedTools), { "Skill:claude-api": true }, "the composite Skill grant key must round-trip through persist/rehydrate intact"); var bridge = makeBridge(sm2); @@ -201,7 +213,7 @@ test("lr-f969dc: resumeSession() hydrates a composite Skill grant and it stays s allowedTools: { "Skill:claude-api": true }, }, null); - assert.deepEqual(resumed.allowedTools, { "Skill:claude-api": true }, + assert.deepEqual(plain(resumed.allowedTools), { "Skill:claude-api": true }, "resumeSession must hydrate the composite Skill grant key from opts"); var bridge = makeBridge(sm); @@ -231,7 +243,7 @@ test("lr-f969dc: composite Skill keys survive sanitizeAllowedTools() — strict- "Skill": "yes", // malformed — non-boolean value must still be dropped "Bash": true, }); - assert.deepEqual(sanitized, { + assert.deepEqual(plain(sanitized), { "Skill:claude-api": true, "Skill:lore-commit": true, "Bash": true, diff --git a/test/phantom-notification-badges-lr-13c047.test.js b/test/phantom-notification-badges-lr-13c047.test.js index 79b2d032..c82a7ea0 100644 --- a/test/phantom-notification-badges-lr-13c047.test.js +++ b/test/phantom-notification-badges-lr-13c047.test.js @@ -80,7 +80,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, onSessionDeleted) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -143,7 +143,7 @@ test("lib/sessions.js: onSessionDeleted defaults to a no-op when not supplied (n var tmpHome = makeTempHome(); try { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -334,7 +334,7 @@ function requireFreshTerminalManager() { // cached `.exports` in place specifically so that busting-then-refreshing // it wouldn't discard the stub before terminal-manager.js's own // `require("./terminal")` call picks it up. - try { delete require.cache[require.resolve("../lib/terminal-manager")]; } catch (_) {} + delete require.cache[require.resolve("../lib/terminal-manager")]; return require("../lib/terminal-manager"); } diff --git a/test/project-access-units.test.js b/test/project-access-units.test.js new file mode 100644 index 00000000..5b477ace --- /dev/null +++ b/test/project-access-units.test.js @@ -0,0 +1,329 @@ +// The access primitives: the project access resolver, the per-message gate, +// the permission module's visibility default, and the daemon's access record. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var os = require("os"); +var path = require("path"); +var fs = require("fs"); + +var SANDBOX = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-access-units-")); +process.env.CLAGENTIC_CONSOLE_HOME = path.join(SANDBOX, "data"); + +var { attachPermissions } = require("../lib/users-permissions"); +var { createProjectAccess } = require("../lib/project-access"); +var realUsers = require("../lib/users"); +var { createMessageGate, DENIED } = require("../lib/project-message-gate"); +var daemonProjects = require("../lib/daemon-projects"); + +test.after(function () { fs.rmSync(SANDBOX, { recursive: true, force: true }); }); + +var PEOPLE = { + admin: { id: "admin", role: "admin" }, + owner: { id: "owner", role: "user" }, + member: { id: "member", role: "user" }, + other: { id: "other", role: "user" }, +}; +var users = attachPermissions({ + loadUsers: function () { return { users: [] }; }, + saveUsers: function () {}, + findUserById: function (id) { return PEOPLE[id] || null; }, +}); + +// --- visibility ------------------------------------------------------------- + +test("a project record with no visibility is private, not public", function () { + var record = { ownerId: "owner", allowedUsers: ["member"] }; + assert.equal(users.canAccessProject("other", record), false); + assert.equal(users.canAccessProject("member", record), true); + assert.equal(users.canAccessProject("owner", record), true); + assert.equal(users.canAccessProject("admin", record), true); + assert.equal(users.canAccessProject("other", { visibility: "something-new", ownerId: "owner" }), false); + assert.equal(users.canAccessProject("other", { visibility: "public" }), true); + assert.equal(users.canAccessProject("other", null), false); +}); + +// --- resolver --------------------------------------------------------------- + +function accessWith(table, isMultiUser) { + return createProjectAccess({ + users: users, + isMultiUser: isMultiUser, + onGetProjectAccess: function (slug) { + if (slug === "throws") throw new Error("lookup exploded"); + return Object.prototype.hasOwnProperty.call(table, slug) ? table[slug] : { error: "Project not found" }; + }, + }); +} + +var SINGLE_USER = function () { return false; }; +var MULTI_USER = function () { return true; }; + +test("a lookup that cannot be completed is a refusal, for every user including administrators", function () { + var access = accessWith({ open: { visibility: "public" } }); + assert.equal(access.canAccess("member", "open"), true); + assert.equal(access.canAccess("admin", "missing"), false); + assert.equal(access.canAccess("admin", "throws"), false); + assert.equal(access.canAccess("admin", undefined), false); + assert.equal(access.canAccess("admin", {}), false); + assert.equal(access.canAccess(null, "open"), false); + assert.equal(access.canAccess("member", "__proto__"), false); + assert.equal(access.canAccess("member", "constructor"), false); +}); + +test("with no lookup wired nothing is accessible", function () { + var access = createProjectAccess({ users: users, onGetProjectAccess: null }); + assert.equal(access.canAccess("admin", "anything"), false); + assert.deepEqual(access.filterProjectList("admin", [{ slug: "anything" }]), []); +}); + +test("a project list is filtered to what the user may see and is empty for no user", function () { + var access = accessWith({ + open: { visibility: "public" }, + closed: { visibility: "private", ownerId: "owner" }, + unmarked: { ownerId: "owner" }, + }); + var list = [{ slug: "open" }, { slug: "closed" }, { slug: "unmarked" }, { slug: "gone" }, null]; + assert.deepEqual(access.filterProjectList("member", list).map(function (p) { return p.slug; }), ["open"]); + assert.deepEqual(access.filterProjectList("owner", list).map(function (p) { return p.slug; }), ["open", "closed", "unmarked"]); + assert.deepEqual(access.filterProjectList("admin", list).map(function (p) { return p.slug; }), ["open", "closed", "unmarked"]); + assert.deepEqual(access.filterProjectList(null, list), []); + assert.deepEqual(access.filterProjectList("member", "nope"), []); +}); + +test("a session is readable by its owner, by anyone when shared, and by an administrator when legacy", function () { + var access = accessWith({}, MULTI_USER); + var canReadSession = access.canReadSession; + assert.equal(canReadSession(PEOPLE.owner, { ownerId: "owner", sessionVisibility: "private" }), true); + assert.equal(canReadSession(PEOPLE.member, { ownerId: "owner", sessionVisibility: "private" }), false); + assert.equal(canReadSession(PEOPLE.member, { ownerId: "owner" }), true); + assert.equal(canReadSession(PEOPLE.member, { ownerId: null }), false); + assert.equal(canReadSession(PEOPLE.admin, { ownerId: null }), true); + assert.equal(canReadSession(null, { ownerId: "owner" }), false); + assert.equal(canReadSession(PEOPLE.owner, null), false); +}); + +// --- mode: "no user" is decided by the daemon's mode, never by absence alone ---- + +test("in single-user mode a connection with no user is the implicit owner with admin rights and every project", function () { + var access = accessWith({ open: { visibility: "public" }, closed: { visibility: "private", ownerId: "owner" } }, SINGLE_USER); + var proof = { authenticated: true }; + var principal = access.principalFor(null, proof); + assert.deepEqual({ id: principal.id, implicit: principal.implicit, admin: principal.admin }, { id: null, implicit: true, admin: true }); + assert.equal(access.isAdmin(null, proof), true); + assert.equal(access.isAdmin(undefined, proof), true); + assert.equal(access.canAccess(null, "open", proof), true); + assert.equal(access.canAccess(null, "closed", proof), true); + assert.equal(access.canAccess(null, "a--worktree", proof), true, "a worktree slug needs no lookup for the owner"); + assert.equal(access.canAccess(null, undefined, proof), false, "a slug that is not a name is still refused"); + assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }, { slug: "closed" }, null], proof).map(function (p) { return p.slug; }), ["open", "closed"]); + assert.equal(access.canReadSession(null, { ownerId: "someone", sessionVisibility: "private" }, proof), true); + assert.equal(access.canReadSession(null, null, proof), false); +}); + +test("in single-user mode no user is nobody unless the caller proved the login, whatever the proof's shape", function () { + var access = accessWith({ open: { visibility: "public" } }, SINGLE_USER); + [undefined, null, {}, { authenticated: false }, { authenticated: "true" }, { authenticated: 1 }, "authenticated", true].forEach(function (proof) { + var shown = JSON.stringify(proof); + assert.equal(access.principalFor(null, proof), null, shown); + assert.equal(access.isAdmin(null, proof), false, shown); + assert.equal(access.canAccess(null, "open", proof), false, shown); + assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }], proof), [], shown); + assert.equal(access.canReadSession(null, { ownerId: "owner" }, proof), false, shown); + }); + assert.equal(access.principalFor(null), null, "no proof argument at all"); +}); + +test("a named user needs no proof, and a proof alone does not make a user", function () { + var access = accessWith({ open: { visibility: "public" } }, SINGLE_USER); + assert.equal(access.principalFor(PEOPLE.member).id, "member"); + assert.equal(access.canAccess("member", "open"), true); + assert.equal(access.principalFor({}, { authenticated: true }), null, "a record with no id stays refused"); +}); + +test("authOf reads the proof a socket or request carries and nothing else", function () { + var access = accessWith({}, SINGLE_USER); + assert.deepEqual(access.authOf({ _clagenticAuthenticated: true }), { authenticated: true }); + [null, undefined, {}, { _clagenticAuthenticated: false }, { _clagenticAuthenticated: "true" }, { authenticated: true }, { _clagenticUser: PEOPLE.owner }].forEach(function (conn) { + assert.deepEqual(access.authOf(conn), { authenticated: false }, JSON.stringify(conn)); + }); + assert.equal(access.canAccess(null, "x", access.authOf({ _clagenticAuthenticated: true })), true); +}); + +test("in single-user mode a named user keeps exactly the rights of that user", function () { + var access = accessWith({ closed: { visibility: "private", ownerId: "owner" } }, SINGLE_USER); + assert.equal(access.canAccess("member", "closed"), false); + assert.equal(access.isAdmin(PEOPLE.member), false); + assert.equal(access.isAdmin(PEOPLE.admin), true); +}); + +test("in multi-user mode, with no mode wired, or when the mode cannot be read, no user is a stranger", function () { + [accessWith({ open: { visibility: "public" } }, MULTI_USER), + accessWith({ open: { visibility: "public" } }, undefined), + accessWith({ open: { visibility: "public" } }, function () { throw new Error("users.json unreadable"); })].forEach(function (access) { + var proof = { authenticated: true }; + assert.equal(access.principalFor(null), null); + assert.equal(access.principalFor(null, proof), null, "a proven login without a user is still nobody"); + assert.equal(access.principalFor(undefined, proof), null); + assert.equal(access.principalFor({}, proof), null, "a record with no id is no principal"); + assert.equal(access.isAdmin(null, proof), false); + assert.equal(access.canAccess(null, "open", proof), false); + assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }], proof), []); + assert.equal(access.canReadSession(null, { ownerId: "owner" }, proof), false); + }); +}); + +test("the daemon's mode is read from the stored users and defaults to multi-user", function () { + var dir = path.join(process.env.CLAGENTIC_CONSOLE_HOME, "console"); + fs.mkdirSync(dir, { recursive: true }); + var file = path.join(dir, "users.json"); + try { + fs.rmSync(file, { force: true }); + assert.equal(realUsers.isMultiUser(), true, "no users file"); + fs.writeFileSync(file, JSON.stringify({ users: [] })); + assert.equal(realUsers.isMultiUser(), true, "flag absent"); + fs.writeFileSync(file, JSON.stringify({ multiUser: false, users: [] })); + assert.equal(realUsers.isMultiUser(), false); + fs.writeFileSync(file, "{ not json"); + assert.throws(function () { realUsers.isMultiUser(); }, "a corrupt file is an error, which the resolver reads as multi-user"); + var viaResolver = createProjectAccess({ users: users, onGetProjectAccess: null, isMultiUser: realUsers.isMultiUser }); + assert.equal(viaResolver.principalFor(null, { authenticated: true }), null); + } finally { + fs.rmSync(file, { force: true }); + } +}); + +// --- message gate ------------------------------------------------------------- + +function gateFor(projects, extra) { + return gateWith(projects, MULTI_USER, extra); +} + +function gateWith(projects, isMultiUser, extra) { + var contexts = {}; + Object.keys(projects).forEach(function (slug) { contexts[slug] = { slug: slug }; }); + var access = accessWith(projects, isMultiUser); + return createMessageGate(Object.assign({ + slug: "here", + canAccess: access.canAccess, + getProject: function (s) { return Object.prototype.hasOwnProperty.call(contexts, s) ? contexts[s] : null; }, + }, extra || {})); +} + +var TABLE = { + here: { visibility: "public" }, + there: { visibility: "private", ownerId: "owner" }, + worktree: { visibility: "public" }, +}; + +test("a message for the current project passes with its content untouched", function () { + var gate = gateFor(TABLE); + var msg = { type: "x", n: 1 }; + var r = gate.authorize({ _clagenticUser: PEOPLE.member }, msg); + assert.equal(r.allowed, true); + assert.equal(r.target, null); + assert.deepEqual(r.message, msg); +}); + +test("the current project is re-checked on every message", function () { + var gate = gateFor({ here: { visibility: "private", ownerId: "owner" } }); + var r = gate.authorize({ _clagenticUser: PEOPLE.member }, { type: "x" }); + assert.equal(r.allowed, false); + assert.equal(r.error, DENIED); + assert.equal(gate.authorize({ _clagenticUser: null }, { type: "x" }).allowed, false); +}); + +test("a targetSlug is authorized and resolved, and removed before the handler sees the message", function () { + var gate = gateFor(TABLE); + var r = gate.authorize({ _clagenticUser: PEOPLE.owner }, { type: "x", targetSlug: "there", id: 4 }); + assert.equal(r.allowed, true); + assert.equal(r.target.slug, "there"); + assert.deepEqual(r.message, { type: "x", id: 4 }); + var own = gate.authorize({ _clagenticUser: PEOPLE.member }, { type: "x", targetSlug: "here" }); + assert.equal(own.allowed, true); + assert.equal(own.target, null); + assert.deepEqual(own.message, { type: "x" }); +}); + +test("a targetSlug the user cannot use, that is unknown, or that is not a string is refused", function () { + var gate = gateFor(TABLE); + var ws = { _clagenticUser: PEOPLE.member }; + ["there", "nowhere", "__proto__", "constructor", 5, {}, ["there"], true].forEach(function (target) { + var r = gate.authorize(ws, { type: "x", targetSlug: target }); + assert.equal(r.allowed, false, JSON.stringify(target)); + assert.equal(r.error, DENIED); + }); +}); + +test("a worktree slug is authorized through the same lookup", function () { + var gate = gateFor(TABLE); + assert.equal(gate.authorize({ _clagenticUser: PEOPLE.member }, { type: "x", targetSlug: "worktree" }).allowed, true); +}); + +test("a message that is not a JSON object is refused without an error to send", function () { + var gate = gateFor(TABLE); + [null, undefined, 5, "text", [1], true].forEach(function (bad) { + var r = gate.authorize({ _clagenticUser: PEOPLE.member }, bad); + assert.equal(r.allowed, false); + assert.equal(r.error, null); + }); +}); + +test("the gate lets the single-user owner (no user record) act on the current and any other project", function () { + var gate = gateWith(TABLE, SINGLE_USER); + var owner = { _clagenticUser: null, _clagenticAuthenticated: true }; + var own = gate.authorize(owner, { type: "x" }); + assert.equal(own.allowed, true); + var other = gate.authorize(owner, { type: "x", targetSlug: "there", id: 4 }); + assert.equal(other.allowed, true); + assert.equal(other.target.slug, "there"); + assert.deepEqual(other.message, { type: "x", id: 4 }); + assert.equal(gate.authorize(owner, { type: "x", targetSlug: "nowhere" }).allowed, false, "a project that does not exist is still refused"); + assert.equal(gate.authorize(owner, { type: "x", targetSlug: 5 }).allowed, false); +}); + +test("the gate refuses a single-user connection that never passed the upgrade login check", function () { + var gate = gateWith(TABLE, SINGLE_USER); + [{ _clagenticUser: null }, { _clagenticUser: null, _clagenticAuthenticated: false }, {}, null].forEach(function (ws) { + assert.equal(gate.authorize(ws, { type: "x" }).allowed, false, JSON.stringify(ws)); + assert.equal(gate.authorize(ws, { type: "x", targetSlug: "there" }).allowed, false, JSON.stringify(ws)); + }); +}); + +test("the gate refuses a connection with no user in multi-user mode, on every message shape", function () { + var gate = gateWith(TABLE, MULTI_USER); + assert.equal(gate.authorize({ _clagenticUser: null }, { type: "x" }).allowed, false); + assert.equal(gate.authorize({ _clagenticUser: null }, { type: "x", targetSlug: "there" }).allowed, false); + assert.equal(gate.authorize({}, { type: "x" }).allowed, false); + assert.equal(gate.authorize(null, { type: "x" }).allowed, false); +}); + +test("a context with no access policy handles its own messages but never forwards to another project", function () { + var gate = createMessageGate({ slug: "here", canAccess: null, getProject: function () { return { slug: "there" }; } }); + assert.equal(gate.authorize({ _clagenticUser: null }, { type: "x" }).allowed, true); + assert.equal(gate.authorize({ _clagenticUser: PEOPLE.admin }, { type: "x", targetSlug: "there" }).allowed, false); +}); + +// --- the daemon's access record ------------------------------------------------- + +test("the daemon gives a registered worktree its parent's access record", function () { + var config = { osUsers: false, projects: [ + { slug: "parent", visibility: "private", ownerId: "owner", allowedUsers: ["member"] }, + { slug: "plain" }, + ] }; + daemonProjects.registerWorktreeSlug("parent", "parent--feature"); + var wt = daemonProjects.getProjectAccessRecord(config, "parent--feature"); + assert.equal(wt.error, undefined); + assert.equal(wt.visibility, "private"); + assert.equal(wt.ownerId, "owner"); + assert.deepEqual(wt.allowedUsers, ["member"]); + assert.ok(daemonProjects.getProjectAccessRecord(config, "unregistered--wt").error); + assert.ok(daemonProjects.getProjectAccessRecord(config, "missing").error); + assert.equal(daemonProjects.getProjectAccessRecord(config, "plain").visibility, "public"); + config.osUsers = true; + assert.equal(daemonProjects.getProjectAccessRecord(config, "plain").visibility, "private"); + daemonProjects.unregisterWorktreeSlug("parent", "parent--feature"); + assert.ok(daemonProjects.getProjectAccessRecord(config, "parent--feature").error, "an unregistered worktree has no record"); +}); diff --git a/test/project-connection-hydrate-session-model-lr-041af8.test.js b/test/project-connection-hydrate-session-model-lr-041af8.test.js index 4e827108..9a5ece61 100644 --- a/test/project-connection-hydrate-session-model-lr-041af8.test.js +++ b/test/project-connection-hydrate-session-model-lr-041af8.test.js @@ -43,7 +43,7 @@ var REQUIRE_CACHE_MODULES = [ function bustRequireCache() { REQUIRE_CACHE_MODULES.forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); } diff --git a/test/project-connection-ownership-claim-lr-768c9e.test.js b/test/project-connection-ownership-claim-lr-768c9e.test.js index 907ff58e..144c9eb0 100644 --- a/test/project-connection-ownership-claim-lr-768c9e.test.js +++ b/test/project-connection-ownership-claim-lr-768c9e.test.js @@ -45,7 +45,7 @@ var REQUIRE_CACHE_MODULES = [ function bustRequireCache() { REQUIRE_CACHE_MODULES.forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); } diff --git a/test/project-connection-restore-lastactivity-tie.test.js b/test/project-connection-restore-lastactivity-tie.test.js index c72e577a..bde8aad2 100644 --- a/test/project-connection-restore-lastactivity-tie.test.js +++ b/test/project-connection-restore-lastactivity-tie.test.js @@ -23,7 +23,7 @@ var REQUIRE_CACHE_MODULES = [ function bustRequireCache() { REQUIRE_CACHE_MODULES.forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); } diff --git a/test/project-file-watch-per-connection.test.js b/test/project-file-watch-per-connection.test.js new file mode 100644 index 00000000..a6dca1ad --- /dev/null +++ b/test/project-file-watch-per-connection.test.js @@ -0,0 +1,209 @@ +// File and directory watches belong to the connection that opened them: a +// change is read as that connection's user and sent to that connection only. +// Real files and real fs.watch; the sockets are plain objects. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var os = require("os"); +var path = require("path"); + +var SANDBOX = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-watch-")); +process.env.HOME = path.join(SANDBOX, "home"); +process.env.CLAGENTIC_CONSOLE_HOME = path.join(SANDBOX, "data"); + +var { attachFileWatch } = require("../lib/project-file-watch"); +var { createFileAccess, safePath } = require("../lib/project-file-scope"); + +var ROOT = path.join(SANDBOX, "project"); +var watchers = []; + +test.before(function () { + fs.mkdirSync(path.join(ROOT, "dir"), { recursive: true }); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v0"); +}); + +test.after(function () { + watchers.forEach(function (w) { w.stopAll(); }); + fs.rmSync(SANDBOX, { recursive: true, force: true }); +}); + +function pause(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } + +function socket(name, user, osInfo) { + return { name: name, readyState: 1, _clagenticUser: user, osInfo: osInfo || null, sent: [] }; +} + +// A watch engine wired the way lib/project.js wires it. +function engine(options) { + options = options || {}; + var asUserCalls = []; + var fsAsUser = function (op, args, info) { + asUserCalls.push({ op: op, file: args.file || args.dir, uid: info.uid }); + if (op === "stat") return { size: 4 }; + if (op === "read") return { size: 4, content: "uid" + info.uid }; + return [{ name: "seen-by-" + info.uid, isDir: false }]; + }; + var usersModule = { + getEffectivePermissions: function (user) { + return { fileBrowser: !(user && user.permissions && user.permissions.fileBrowser === false) }; + }, + }; + var osUsers = !!options.osUsers; + var access = createFileAccess({ + osUsers: osUsers, usersModule: usersModule, fsAsUser: fsAsUser, + getOsUserInfoForWs: function (ws) { return osUsers ? ws.osInfo : null; }, + binaryExts: new Set([".png"]), maxSize: 512 * 1024, + }); + var w = attachFileWatch({ + cwd: ROOT, + sendTo: function (ws, obj) { ws.sent.push(obj); }, + safePath: safePath, + authorize: access.authorize, + readFile: access.readFile, + listDir: access.listDir, + IGNORED_DIRS: new Set([".git"]), + }); + watchers.push(w); + w.asUserCalls = asUserCalls; + return w; +} + +function changed(ws) { + return ws.sent.filter(function (m) { return m.type === "fs_file_changed"; }); +} + +test("a file change is sent to the connection that watches it and to no other", async function () { + var w = engine(); + var watcher = socket("watcher", { id: "u1" }); + var bystander = socket("bystander", { id: "u2" }); + w.startFileWatch(watcher, "watched.txt"); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v1"); + await pause(700); + assert.equal(changed(watcher).length >= 1, true); + assert.equal(changed(watcher)[0].content, "v1"); + assert.equal(changed(bystander).length, 0); + w.stopFileWatch(watcher); +}); + +test("two connections watching the same file are each sent the change", async function () { + var w = engine(); + var a = socket("a", { id: "u1" }); + var b = socket("b", { id: "u2" }); + w.startFileWatch(a, "watched.txt"); + w.startFileWatch(b, "watched.txt"); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v2"); + await pause(700); + assert.equal(changed(a)[0].content, "v2"); + assert.equal(changed(b)[0].content, "v2"); + w.stopFileWatch(a); + assert.equal(a.sent.length > 0, true); + var before = b.sent.length; + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v3"); + await pause(700); + assert.equal(changed(a).filter(function (m) { return m.content === "v3"; }).length, 0, "a stopped watch sends nothing"); + assert.ok(b.sent.length > before, "the other connection's watch is unaffected"); + w.stopFileWatch(b); +}); + +test("a connection without the fileBrowser permission cannot start a watch", async function () { + var w = engine(); + var denied = socket("denied", { id: "u3", permissions: { fileBrowser: false } }); + w.startFileWatch(denied, "watched.txt"); + w.startDirWatch(denied, "dir"); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v4"); + fs.writeFileSync(path.join(ROOT, "dir", "new.txt"), "n"); + await pause(700); + assert.deepEqual(denied.sent, []); +}); + +test("a watch ends when its connection loses the permission, without sending the file", async function () { + var w = engine(); + var user = { id: "u4" }; + var ws = socket("revoked", user); + w.startFileWatch(ws, "watched.txt"); + user.permissions = { fileBrowser: false }; + fs.writeFileSync(path.join(ROOT, "watched.txt"), "secret-after-revoke"); + await pause(700); + assert.deepEqual(changed(ws), []); +}); + +test("a path outside the project is not watched", async function () { + var w = engine(); + var ws = socket("escaper", { id: "u5" }); + var outsideFile = path.join(SANDBOX, "outside-watched.txt"); + fs.writeFileSync(outsideFile, "o0"); + w.startFileWatch(ws, "../project/../../etc/hostname"); + w.startFileWatch(ws, "../outside-watched.txt"); + w.startFileWatch(ws, outsideFile); + w.startFileWatch(ws, 7); + w.startFileWatch(ws, "watched.txt\u0000"); + w.startDirWatch(ws, ".."); + w.startDirWatch(ws, SANDBOX); + + // A control watch that is allowed shows that changes are being delivered + // in this window, so silence on the escaper is a real result. + var control = socket("control", { id: "u6" }); + w.startFileWatch(control, "watched.txt"); + + // Change everything a wrongly started watch would have been watching. + fs.writeFileSync(outsideFile, "o1"); + fs.writeFileSync(path.join(SANDBOX, "created-in-parent.txt"), "p"); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "inside-change"); + await pause(800); + + assert.ok(changed(control).length >= 1, "the permitted watch saw the change"); + assert.deepEqual(ws.sent, [], "no escaping watch was started, so nothing was sent"); + w.stopFileWatch(control); +}); + +test("in os-users mode a change is read as each connection's own identity, never as the daemon", async function () { + var w = engine({ osUsers: true }); + var a = socket("a", { id: "u1" }, { uid: 1001, gid: 1001, home: "/h/a" }); + var b = socket("b", { id: "u2" }, { uid: 1002, gid: 1002, home: "/h/b" }); + var noIdentity = socket("none", { id: "u3" }, null); + w.startFileWatch(a, "watched.txt"); + w.startFileWatch(b, "watched.txt"); + w.startFileWatch(noIdentity, "watched.txt"); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v5"); + await pause(700); + assert.equal(changed(a)[0].content, "uid1001"); + assert.equal(changed(b)[0].content, "uid1002"); + assert.deepEqual(noIdentity.sent, [], "a connection with no OS identity is not served by a daemon read"); + w.asUserCalls.forEach(function (c) { assert.ok(c.uid === 1001 || c.uid === 1002); }); + w.stopFileWatch(a); + w.stopFileWatch(b); +}); + +test("a directory change is sent, as that user, to the connection that listed it only", async function () { + var w = engine({ osUsers: true }); + var lister = socket("lister", { id: "u1" }, { uid: 2001, gid: 2001, home: "/h" }); + var other = socket("other", { id: "u2" }, { uid: 2002, gid: 2002, home: "/h" }); + w.startDirWatch(lister, "dir"); + fs.writeFileSync(path.join(ROOT, "dir", "another.txt"), "x"); + await pause(800); + var dirMsgs = lister.sent.filter(function (m) { return m.type === "fs_dir_changed"; }); + assert.ok(dirMsgs.length >= 1); + assert.deepEqual(dirMsgs[0].entries.map(function (e) { return e.name; }), ["seen-by-2001"]); + assert.deepEqual(other.sent, []); + w.stopAllDirWatches(lister); +}); + +test("disconnecting releases a connection's watches and nothing else", async function () { + var w = engine(); + var leaving = socket("leaving", { id: "u1" }); + var staying = socket("staying", { id: "u2" }); + w.startFileWatch(leaving, "watched.txt"); + w.startDirWatch(leaving, "dir"); + w.startFileWatch(staying, "watched.txt"); + w.stopFileWatch(leaving); + w.stopAllDirWatches(leaving); + fs.writeFileSync(path.join(ROOT, "watched.txt"), "v6"); + fs.writeFileSync(path.join(ROOT, "dir", "x.txt"), "x"); + await pause(800); + assert.deepEqual(leaving.sent, []); + assert.equal(changed(staying).length >= 1, true); + w.stopFileWatch(staying); +}); diff --git a/test/project-filesystem-policy.test.js b/test/project-filesystem-policy.test.js new file mode 100644 index 00000000..27bbfa3c --- /dev/null +++ b/test/project-filesystem-policy.test.js @@ -0,0 +1,632 @@ +// The WebSocket file handlers (lib/project-filesystem.js) and the file-scope +// policy they share with the HTTP route and the watchers +// (lib/project-file-scope.js, lib/project-git.js). +// +// Real modules and a real git repository throughout; the only fakes are the +// socket, the session store and the OS-user helper (fsAsUser), which would +// need root and real accounts. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var os = require("os"); +var path = require("path"); +var { execFileSync } = require("child_process"); + +var SANDBOX = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-fs-policy-")); +process.env.HOME = path.join(SANDBOX, "home"); +fs.mkdirSync(path.join(process.env.HOME, ".claude"), { recursive: true }); +process.env.CLAGENTIC_CONSOLE_HOME = path.join(SANDBOX, "data"); + +var { attachPermissions } = require("../lib/users-permissions"); +var { attachFilesystem } = require("../lib/project-filesystem"); +var { createFileAccess, resolveGitPath } = require("../lib/project-file-scope"); +var projectGit = require("../lib/project-git"); +var { createProjectAccess } = require("../lib/project-access"); +var { validateEnvString } = require("../lib/project"); + +var REPO = path.join(SANDBOX, "repo"); +var OUTSIDE = path.join(SANDBOX, "outside.txt"); +var HASH = null; + +function git(args) { + return execFileSync("git", args, { + cwd: REPO, encoding: "utf8", + env: Object.assign({}, process.env, { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }), + }); +} + +test.before(function () { + fs.mkdirSync(path.join(REPO, "src"), { recursive: true }); + fs.writeFileSync(OUTSIDE, "outside"); + git(["init", "-q"]); + fs.writeFileSync(path.join(REPO, "src", "a.txt"), "one\n"); + git(["add", "src/a.txt"]); + git(["commit", "-q", "-m", "first"]); + fs.writeFileSync(path.join(REPO, "src", "a.txt"), "one\ntwo\n"); + git(["commit", "-q", "-am", "second"]); + HASH = git(["rev-parse", "HEAD"]).trim(); +}); + +test.after(function () { + fs.rmSync(SANDBOX, { recursive: true, force: true }); +}); + +var USERS = { + admin: { id: "u-admin", username: "admin", role: "admin" }, + member: { id: "u-member", username: "member", role: "user", permissions: { projectSettings: true } }, + nofiles: { id: "u-nofiles", username: "nofiles", role: "user", permissions: { fileBrowser: false } }, + osmember: { id: "u-os", username: "osmember", role: "user", linuxUser: "osmember", permissions: { fileBrowser: true } }, +}; + +var usersModule = attachPermissions({ + loadUsers: function () { return { users: Object.keys(USERS).map(function (k) { return USERS[k]; }) }; }, + saveUsers: function () {}, + findUserById: function (id) { + return Object.keys(USERS).map(function (k) { return USERS[k]; }).filter(function (u) { return u.id === id; })[0] || null; + }, +}); + +// A connection that passed the WebSocket upgrade's login check, which is what +// marks it authenticated. `unproven` builds one that never did. +function fakeSocket(user, osInfo, unproven) { + return { readyState: 1, _clagenticUser: user || null, _clagenticAuthenticated: !unproven, osInfo: osInfo || null, sent: [] }; +} + +// A project's filesystem handler wired as lib/project.js wires it. +// options.osUsers os-users mode +// options.multiUser the daemon's mode; single-user otherwise, where a socket +// with no user is the implicit owner +function build(options) { + options = options || {}; + var calls = { env: [], sharedEnv: [], fsAsUser: [], watch: [], unwatch: 0, dirWatch: [] }; + var sessions = new Map(); + var osUsers = !!options.osUsers; + var projectAccess = createProjectAccess({ + users: usersModule, + onGetProjectAccess: null, + isMultiUser: function () { return !!options.multiUser; }, + }); + function getOsUserInfoForWs(ws) { return osUsers ? ws.osInfo : null; } + function fsAsUser(op, args, info) { + calls.fsAsUser.push({ op: op, args: args, info: info }); + if (op === "stat") return { size: 5 }; + if (op === "read") return { size: 5, content: "as-user" }; + if (op === "list") return [{ name: "listed.txt", isDir: false }]; + return { ok: true }; + } + var fileAccess = createFileAccess({ + osUsers: osUsers, usersModule: usersModule, fsAsUser: fsAsUser, projectAccess: projectAccess, + getOsUserInfoForWs: getOsUserInfoForWs, + binaryExts: new Set([".png"]), maxSize: 512 * 1024, + }); + var handlers = attachFilesystem({ + cwd: REPO, slug: "mine", osUsers: osUsers, + sm: { sessions: sessions, readSessionHistoryFromDisk: function (s) { return s.history; } }, + send: function () {}, + sendTo: function (ws, obj) { ws.sent.push(obj); }, + fileAccess: fileAccess, + getOsUserInfoForWs: getOsUserInfoForWs, + startFileWatch: function (ws, p) { calls.watch.push(p); }, + stopFileWatch: function () { calls.unwatch++; }, + startDirWatch: function (ws, p) { calls.dirWatch.push(p); }, + usersModule: usersModule, fsAsUser: fsAsUser, validateEnvString: validateEnvString, + projectAccess: projectAccess, + opts: { + onGetProjectEnv: function (s) { calls.env.push(["get", s]); return { envrc: "A=1" }; }, + onSetProjectEnv: function (s, e) { calls.env.push(["set", s, e]); return { ok: true }; }, + onGetSharedEnv: function () { calls.sharedEnv.push("get"); return { envrc: "S=1" }; }, + onSetSharedEnv: function (e) { calls.sharedEnv.push(["set", e]); return { ok: true }; }, + }, + IGNORED_DIRS: new Set([".git", "node_modules"]), + IMAGE_EXTS: new Set([".png"]), + }); + return { handlers: handlers, calls: calls, sessions: sessions }; +} + +function send(ws, rig, msg) { + var handled = rig.handlers.handleFilesystemMessage(ws, msg); + return { handled: handled, last: ws.sent[ws.sent.length - 1] }; +} + +// --- Project env uses the authorized slug ---------------------------------- + +test("project env messages act on the project they arrive in, whatever slug they carry", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + var got = send(ws, rig, { type: "get_project_env", slug: "someone-elses" }).last; + assert.equal(got.slug, "mine"); + var set = send(ws, rig, { type: "set_project_env", slug: "someone-elses", envrc: "B=2" }).last; + assert.equal(set.slug, "mine"); + assert.deepEqual(rig.calls.env, [["get", "mine"], ["set", "mine", "B=2"]]); +}); + +test("project env refuses a value that is not text", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + var res = send(ws, rig, { type: "set_project_env", envrc: { split: 1 } }).last; + assert.equal(res.ok, false); + assert.deepEqual(rig.calls.env, []); +}); + +test("project env still needs the projectSettings permission", function () { + var rig = build(); + var res = send(fakeSocket(USERS.nofiles), rig, { type: "get_project_env" }).last; + assert.equal(res.type, "error"); + assert.deepEqual(rig.calls.env, []); +}); + +// --- Global settings are administrator-only ---------------------------------- + +test("the global CLAUDE.md and the shared environment are for administrators only", function () { + var globalMd = path.join(process.env.HOME, ".claude", "CLAUDE.md"); + fs.writeFileSync(globalMd, "original"); + var rig = build({ multiUser: true }); + var member = fakeSocket(USERS.member); + ["read_global_claude_md", "write_global_claude_md", "get_shared_env", "set_shared_env"].forEach(function (type) { + var res = send(member, rig, { type: type, content: "pwned", envrc: "X=1" }).last; + assert.equal(res.type, "error", type + " is refused to a member who holds projectSettings"); + assert.match(res.text, /admin/i); + }); + var anonymous = fakeSocket(null); + ["read_global_claude_md", "write_global_claude_md", "get_shared_env", "set_shared_env"].forEach(function (type) { + var res = send(anonymous, rig, { type: type, content: "pwned", envrc: "X=1" }).last; + assert.equal(res.type, "error", type + " is refused when there is no user in multi-user mode"); + }); + assert.equal(fs.readFileSync(globalMd, "utf8"), "original"); + assert.deepEqual(rig.calls.sharedEnv, []); + + var admin = fakeSocket(USERS.admin); + assert.equal(send(admin, rig, { type: "read_global_claude_md" }).last.content, "original"); + assert.equal(send(admin, rig, { type: "write_global_claude_md", content: "updated" }).last.ok, true); + assert.equal(fs.readFileSync(globalMd, "utf8"), "updated"); + assert.equal(send(admin, rig, { type: "get_shared_env" }).last.envrc, "S=1"); + assert.equal(send(admin, rig, { type: "set_shared_env", envrc: "S=2" }).last.ok, true); +}); + +// --- Single-user mode: the connection with no user is the owner ------------------- + +test("single-user mode, no user: the owner reads and writes the global CLAUDE.md and the shared environment", function () { + var globalMd = path.join(process.env.HOME, ".claude", "CLAUDE.md"); + fs.writeFileSync(globalMd, "owner-original"); + var rig = build(); + var owner = fakeSocket(null); + assert.equal(send(owner, rig, { type: "read_global_claude_md" }).last.content, "owner-original"); + assert.equal(send(owner, rig, { type: "write_global_claude_md", content: "owner-updated" }).last.ok, true); + assert.equal(fs.readFileSync(globalMd, "utf8"), "owner-updated"); + assert.equal(send(owner, rig, { type: "get_shared_env" }).last.envrc, "S=1"); + assert.equal(send(owner, rig, { type: "set_shared_env", envrc: "S=3" }).last.ok, true); + assert.deepEqual(rig.calls.sharedEnv, ["get", ["set", "S=3"]]); +}); + +test("single-user mode, no user: project env, files, search, watch, history, diff and file-at all work", function () { + var rig = build(); + var owner = fakeSocket(null); + assert.equal(send(owner, rig, { type: "get_project_env", slug: "someone-elses" }).last.slug, "mine"); + assert.equal(send(owner, rig, { type: "set_project_env", envrc: "B=2" }).last.ok, true); + assert.deepEqual(send(owner, rig, { type: "fs_list", path: "src" }).last.entries.map(function (e) { return e.name; }), ["a.txt"]); + assert.equal(send(owner, rig, { type: "fs_read", path: "src/a.txt" }).last.content, "one\ntwo\n"); + assert.equal(send(owner, rig, { type: "fs_search", query: "a.t" }).last.entries[0].path, "src/a.txt"); + send(owner, rig, { type: "fs_watch", path: "src/a.txt" }); + assert.deepEqual(rig.calls.watch, ["src/a.txt"]); + var file = path.join(REPO, "src", "a.txt"); + rig.sessions.set(1, editSession(1, "someone", "private", "legacy-private", file)); + var history = send(owner, rig, { type: "fs_file_history", path: "src/a.txt" }).last; + assert.equal(history.entries.filter(function (e) { return e.source === "git"; }).length, 2); + assert.deepEqual(history.entries.filter(function (e) { return e.source === "session"; }).map(function (e) { return e.sessionTitle; }), ["legacy-private"], "the owner sees every session's edits"); + assert.match(send(owner, rig, { type: "fs_git_diff", path: "src/a.txt", hash: HASH }).last.diff, /\+two/); + assert.equal(send(owner, rig, { type: "fs_file_at", path: "src/a.txt", hash: HASH }).last.content, "one\ntwo\n"); +}); + +test("multi-user mode, no user: every file, settings and git message is refused and nothing throws", function () { + var rig = build({ multiUser: true }); + var stranger = fakeSocket(null); + ["read_global_claude_md", "get_shared_env"].forEach(function (type) { + assert.equal(send(stranger, rig, { type: type }).last.type, "error", type); + }); + assert.equal(send(stranger, rig, { type: "set_shared_env", envrc: "X=1" }).last.type, "error"); + ["fs_list", "fs_read", "fs_write", "fs_search", "fs_watch", "fs_file_history", "fs_git_diff", "fs_file_at"].forEach(function (type) { + var res = send(stranger, rig, { type: type, path: "src/a.txt", content: "x", query: "a", hash: HASH }).last; + assert.match(res.error, /Authentication required/, type); + }); + assert.deepEqual(rig.calls.sharedEnv, []); + assert.deepEqual(rig.calls.watch, []); + assert.equal(fs.readFileSync(path.join(REPO, "src", "a.txt"), "utf8"), "one\ntwo\n", "nothing was written"); +}); + +test("single-user mode, no user and no proof of login: every file, settings and git message is refused", function () { + var rig = build(); + var stranger = fakeSocket(null, null, true); + ["read_global_claude_md", "write_global_claude_md", "get_shared_env", "set_shared_env"].forEach(function (type) { + assert.equal(send(stranger, rig, { type: type, content: "x", envrc: "X=1" }).last.type, "error", type); + }); + ["fs_list", "fs_read", "fs_write", "fs_search", "fs_watch", "fs_file_history", "fs_git_diff", "fs_file_at"].forEach(function (type) { + var res = send(stranger, rig, { type: type, path: "src/a.txt", content: "x", query: "a", hash: HASH }).last; + assert.match(res.error, /Authentication required/, type); + }); + assert.deepEqual(rig.calls.sharedEnv, []); + assert.deepEqual(rig.calls.watch, []); +}); + +// --- The file gate covers every fs_* message but fs_unwatch ------------------ + +test("every fs_ message except fs_unwatch needs the fileBrowser permission", function () { + var rig = build(); + var denied = fakeSocket(USERS.nofiles); + var messages = [ + { type: "fs_list", path: "." }, + { type: "fs_read", path: "src/a.txt" }, + { type: "fs_write", path: "src/a.txt", content: "x" }, + { type: "fs_search", query: "a" }, + { type: "fs_watch", path: "src/a.txt" }, + { type: "fs_file_history", path: "src/a.txt" }, + { type: "fs_git_diff", path: "src/a.txt", hash: HASH }, + { type: "fs_file_at", path: "src/a.txt", hash: HASH }, + { type: "fs_delete", path: "src/a.txt" }, + { type: "fs_rename", path: "src/a.txt" }, + { type: "fs_mkdir", path: "d" }, + { type: "fs_upload", path: "d" }, + ]; + messages.forEach(function (msg) { + var res = send(denied, rig, msg); + assert.equal(res.handled, true, msg.type + " is answered"); + assert.equal(res.last.type, msg.type + "_result", msg.type); + assert.match(res.last.error, /not permitted/, msg.type); + }); + assert.deepEqual(rig.calls.watch, [], "no watch was started"); + assert.equal(fs.readFileSync(path.join(REPO, "src", "a.txt"), "utf8"), "one\ntwo\n", "nothing was written"); + + send(denied, rig, { type: "fs_unwatch" }); + assert.equal(rig.calls.unwatch, 1, "fs_unwatch still releases what the connection holds"); +}); + +test("a user with the fileBrowser permission lists, reads, searches and watches", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + assert.deepEqual(send(ws, rig, { type: "fs_list", path: "src" }).last.entries.map(function (e) { return e.name; }), ["a.txt"]); + assert.equal(send(ws, rig, { type: "fs_read", path: "src/a.txt" }).last.content, "one\ntwo\n"); + assert.equal(send(ws, rig, { type: "fs_search", query: "a.t" }).last.entries[0].path, "src/a.txt"); + send(ws, rig, { type: "fs_watch", path: "src/a.txt" }); + assert.deepEqual(rig.calls.watch, ["src/a.txt"]); + assert.deepEqual(rig.calls.dirWatch, ["src"], "listing registers a watch for this connection"); +}); + +test("paths that are not strings, or leave the project, are refused rather than thrown on", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + [undefined, null, 5, {}, ["a"], "../outside.txt", "src/../../outside.txt", "src/a.txt\u0000x", OUTSIDE].forEach(function (bad) { + ["fs_read", "fs_write", "fs_list"].forEach(function (type) { + // A listing with no path lists the project root, which is not an escape. + if (type === "fs_list" && (bad === undefined || bad === null)) return; + var res = send(ws, rig, { type: type, path: bad, content: "x" }).last; + assert.ok(res.error, type + " " + JSON.stringify(bad) + " is refused"); + }); + }); + assert.equal(fs.readFileSync(OUTSIDE, "utf8"), "outside"); + assert.equal(send(ws, rig, { type: "fs_search", query: { trim: 1 } }).last.entries.length, 0); +}); + +test("a symlink that leaves the project is not followed", function () { + var link = path.join(REPO, "escape"); + fs.symlinkSync(SANDBOX, link); + try { + var rig = build(); + var res = send(fakeSocket(USERS.member), rig, { type: "fs_read", path: "escape/outside.txt" }).last; + assert.match(res.error, /denied/i); + } finally { + fs.unlinkSync(link); + } +}); + +// --- Git: revisions and paths are validated --------------------------------- + +test("a revision that git could read as an option is refused before git runs", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + var marker = path.join(SANDBOX, "option-injection-marker"); + var hostile = ["--output=" + marker, "-p", "--help", "HEAD", "main", "abc;ls", "", "0x12", "12", "g".repeat(8), 123, { a: 1 }, ["abc1234"]]; + hostile.forEach(function (hash) { + var diff = send(ws, rig, { type: "fs_git_diff", path: "src/a.txt", hash: hash || "x", hash2: hash === "" ? null : undefined }).last; + assert.ok(diff.error, "fs_git_diff " + JSON.stringify(hash)); + var secondOperand = send(ws, rig, { type: "fs_git_diff", path: "src/a.txt", hash: HASH, hash2: hash || "x" }).last; + assert.ok(secondOperand.error, "fs_git_diff hash2 " + JSON.stringify(hash)); + var at = send(ws, rig, { type: "fs_file_at", path: "src/a.txt", hash: hash || "x" }).last; + assert.ok(at.error, "fs_file_at " + JSON.stringify(hash)); + }); + assert.equal(fs.existsSync(marker), false, "git was never handed an option"); +}); + +test("a hex revision and a project-relative path still produce the diff and the file at that commit", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + var diff = send(ws, rig, { type: "fs_git_diff", path: "src/a.txt", hash: HASH }).last; + assert.equal(diff.error, undefined); + assert.match(diff.diff, /\+two/); + var short = send(ws, rig, { type: "fs_git_diff", path: "src/a.txt", hash: HASH.slice(0, 8) }).last; + assert.match(short.diff, /\+two/); + var at = send(ws, rig, { type: "fs_file_at", path: "src/a.txt", hash: HASH }).last; + assert.equal(at.content, "one\ntwo\n"); + var history = send(ws, rig, { type: "fs_file_history", path: "src/a.txt" }).last; + assert.equal(history.entries.filter(function (e) { return e.source === "git"; }).length, 2); +}); + +test("git paths must be inside the project", function () { + var rig = build(); + var ws = fakeSocket(USERS.member); + ["../outside.txt", "/etc/passwd", OUTSIDE, "src/../../x", "src/a.txt\u0000", ".", ""].forEach(function (bad) { + var msgs = [ + { type: "fs_git_diff", path: bad || "x", hash: HASH }, + { type: "fs_file_at", path: bad || "x", hash: HASH }, + { type: "fs_file_history", path: bad || "x" }, + ]; + msgs.forEach(function (msg) { + if (bad === "" ) return; + var res = send(ws, rig, msg).last; + assert.ok(res.error, msg.type + " " + JSON.stringify(bad) + " is refused"); + }); + }); + assert.equal(resolveGitPath(REPO, "src/a.txt"), "src/a.txt"); + assert.equal(resolveGitPath(REPO, "./src/../src/a.txt"), "src/a.txt"); + assert.equal(resolveGitPath(REPO, REPO + "/src/a.txt"), "src/a.txt"); + assert.equal(resolveGitPath(REPO, ".."), null); + assert.equal(resolveGitPath(REPO, 5), null); +}); + +// --- File history leaves out sessions the user cannot read ------------------- + +function editSession(localId, ownerId, visibility, title, filePath) { + return { + localId: localId, ownerId: ownerId, sessionVisibility: visibility, title: title, + createdAt: 1000, lastActivity: 2000, + history: [ + { type: "user_message", text: "change " + title }, + { type: "tool_executing", id: "t" + localId, name: "Edit", input: { file_path: filePath, old_string: "one", new_string: "two" } }, + ], + }; +} + +test("file history includes edits from sessions the user can read and leaves out the rest", function () { + var rig = build(); + var file = path.join(REPO, "src", "a.txt"); + rig.sessions.set(1, editSession(1, USERS.member.id, "private", "members-own", file)); + rig.sessions.set(2, editSession(2, USERS.admin.id, "private", "admins-private", file)); + rig.sessions.set(3, editSession(3, USERS.admin.id, "shared", "admins-shared", file)); + var res = send(fakeSocket(USERS.member), rig, { type: "fs_file_history", path: "src/a.txt" }).last; + var titles = res.entries.filter(function (e) { return e.source === "session"; }).map(function (e) { return e.sessionTitle; }).sort(); + assert.deepEqual(titles, ["admins-shared", "members-own"]); + + var asAdmin = send(fakeSocket(USERS.admin), rig, { type: "fs_file_history", path: "src/a.txt" }).last; + var adminTitles = asAdmin.entries.filter(function (e) { return e.source === "session"; }).map(function (e) { return e.sessionTitle; }).sort(); + assert.deepEqual(adminTitles, ["admins-private", "admins-shared"], "the owner of a private session still sees it"); +}); + +// --- os-users mode: no privileged fallback ----------------------------------- + +test("in os-users mode a user without an OS identity is refused and no read happens as the daemon", function () { + var rig = build({ osUsers: true }); + var ws = fakeSocket(USERS.admin, null); + ["fs_list", "fs_read", "fs_write", "fs_search", "fs_watch", "fs_file_history", "fs_git_diff", "fs_file_at"].forEach(function (type) { + var res = send(ws, rig, { type: type, path: "src/a.txt", content: "x", query: "a", hash: HASH }).last; + assert.match(res.error, /OS user identity/, type); + }); + assert.deepEqual(rig.calls.fsAsUser, []); + assert.deepEqual(rig.calls.watch, []); + assert.equal(fs.readFileSync(path.join(REPO, "src", "a.txt"), "utf8"), "one\ntwo\n"); +}); + +test("in os-users mode files are read, listed and written as the user's own identity", function () { + var rig = build({ osUsers: true }); + var info = { uid: 4242, gid: 4242, home: "/home/osmember" }; + var ws = fakeSocket(USERS.osmember, info); + assert.equal(send(ws, rig, { type: "fs_read", path: "src/a.txt" }).last.content, "as-user"); + assert.equal(send(ws, rig, { type: "fs_list", path: "src" }).last.entries[0].name, "listed.txt"); + assert.equal(send(ws, rig, { type: "fs_write", path: "src/a.txt", content: "w" }).last.ok, true); + assert.ok(rig.calls.fsAsUser.length >= 3); + rig.calls.fsAsUser.forEach(function (c) { assert.deepEqual(c.info, info); }); + assert.equal(fs.readFileSync(path.join(REPO, "src", "a.txt"), "utf8"), "one\ntwo\n", "the daemon wrote nothing itself"); +}); + +// --- Git runs as the repository's owner, with repo-driven programs off -------- + +function recordingExec() { + var calls = []; + function exec(bin, argv, execOpts) { calls.push({ bin: bin, argv: argv, opts: execOpts }); return ""; } + exec.calls = calls; + return exec; +} + +test("in os-users mode git runs as the uid that owns the repository, never as the viewer, with no safe.directory override", function () { + var exec = recordingExec(); + projectGit.runGit(["log", "-1"], { cwd: REPO, osUsers: true, exec: exec, osUserInfo: { uid: 4242, gid: 4242, home: "/home/viewer" } }); + var st = fs.statSync(path.join(REPO, ".git")); + var call = exec.calls[0]; + assert.equal(call.opts.uid, st.uid, "the owner's uid, not the viewer's 4242"); + assert.equal(call.opts.gid, st.gid); + assert.ok(call.opts.uid !== 4242); + assert.equal(JSON.stringify(call).indexOf("safe.directory"), -1, "no safe.directory in argv or env"); + assert.equal(call.opts.env.GIT_CONFIG_COUNT, undefined); + assert.notEqual(call.opts.env.HOME, "/home/viewer"); +}); + +test("outside os-users mode git keeps the daemon's identity", function () { + var exec = recordingExec(); + projectGit.runGit(["log", "-1"], { cwd: REPO, osUsers: false, exec: exec }); + assert.equal(exec.calls[0].opts.uid, undefined); + assert.equal(exec.calls[0].opts.gid, undefined); +}); + +test("a repository whose owner cannot be established fails closed", function () { + var exec = recordingExec(); + var noRepo = path.join(SANDBOX, "not-a-repo"); + fs.mkdirSync(noRepo, { recursive: true }); + assert.throws(function () { projectGit.runGit(["log", "-1"], { cwd: noRepo, osUsers: true, exec: exec }); }, /repository owner/); + assert.throws(function () { projectGit.repositoryOwner(path.join(SANDBOX, "missing-dir")); }, /repository owner/); + assert.equal(exec.calls.length, 0, "git was not started"); +}); + +test("a project in a subdirectory of its repository runs git as the repository's owner", function () { + var exec = recordingExec(); + var sub = path.join(REPO, "src"); + var st = fs.statSync(path.join(REPO, ".git")); + assert.equal(projectGit.findRepositoryRoot(sub), REPO); + assert.equal(projectGit.findRepositoryRoot(REPO), REPO); + assert.deepEqual(projectGit.repositoryOwner(sub), { uid: st.uid, gid: st.gid }); + projectGit.runGit(["log", "-1"], { cwd: sub, osUsers: true, exec: exec }); + assert.equal(exec.calls[0].opts.uid, st.uid); + assert.equal(exec.calls[0].opts.gid, st.gid); + assert.equal(exec.calls[0].opts.cwd, sub, "git still runs in the project directory"); +}); + +test("the nearest enclosing repository decides the owner, and a .git file counts", function () { + var outer = fs.mkdtempSync(path.join(SANDBOX, "nested-")); + fs.mkdirSync(path.join(outer, ".git")); + var inner = path.join(outer, "inner"); + fs.mkdirSync(inner); + fs.writeFileSync(path.join(inner, ".git"), "gitdir: ../elsewhere\n"); + var deep = path.join(inner, "a", "b"); + fs.mkdirSync(deep, { recursive: true }); + assert.equal(projectGit.findRepositoryRoot(deep), inner, "a worktree's .git file is the nearest entry"); + assert.equal(projectGit.findRepositoryRoot(path.join(outer, "other-dir-that-does-not-exist")), outer); +}); + +test("a directory with no repository above it is refused, and so is an unreadable one", function () { + var exec = recordingExec(); + var bare = fs.mkdtempSync(path.join(SANDBOX, "bare-")); + assert.equal(projectGit.findRepositoryRoot(bare), null); + assert.throws(function () { projectGit.runGit(["log", "-1"], { cwd: path.join(bare, "sub"), osUsers: true, exec: exec }); }, /repository owner/); + assert.equal(exec.calls.length, 0); +}); + +test("every call switches off the programs a repository can name, and diff rendering skips external drivers and textconv", function () { + var exec = recordingExec(); + ["diff", "show", "log"].forEach(function (sub) { + projectGit.runGit([sub, "x"], { cwd: REPO, exec: exec }); + }); + projectGit.runGit(["status"], { cwd: REPO, exec: exec }); + exec.calls.forEach(function (call) { + var joined = call.argv.join(" "); + assert.match(joined, /-c core\.fsmonitor=false/); + assert.match(joined, /-c core\.hooksPath=\/dev\/null/); + assert.match(joined, /-c diff\.external= /); + }); + exec.calls.slice(0, 3).forEach(function (call) { + assert.ok(call.argv.indexOf("--no-ext-diff") > -1 && call.argv.indexOf("--no-textconv") > -1, call.argv[6]); + }); + assert.equal(exec.calls[3].argv.indexOf("--no-ext-diff"), -1); +}); + +// A repository whose own config tries to run a program in every way git allows. +function hostileRepo() { + var dir = fs.mkdtempSync(path.join(SANDBOX, "hostile-")); + var markers = path.join(dir, "..", path.basename(dir) + "-markers"); + fs.mkdirSync(markers); + // One program per mechanism, each leaving a marker named for it. + function program(name, body) { + var file = path.join(SANDBOX, "mark-" + path.basename(dir) + "-" + name + ".sh"); + fs.writeFileSync(file, "#!/bin/sh\ntouch \"" + markers + "/" + name + "\"\n" + (body || ""), { mode: 0o755 }); + return file; + } + function g(args) { + return execFileSync("git", args, { + cwd: dir, encoding: "utf8", + env: Object.assign({}, process.env, { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }), + }); + } + g(["init", "-q"]); + fs.writeFileSync(path.join(dir, "f.txt"), "one\n"); + fs.writeFileSync(path.join(dir, ".gitattributes"), "*.txt diff=evil\n"); + g(["add", "f.txt", ".gitattributes"]); + g(["commit", "-q", "-m", "first"]); + fs.writeFileSync(path.join(dir, "f.txt"), "one\ntwo\n"); + g(["commit", "-q", "-am", "second"]); + var scripts = [program("textconv", "cat \"$1\"\n"), program("external"), program("fsmonitor")]; + g(["config", "diff.evil.textconv", scripts[0]]); + g(["config", "diff.external", scripts[1]]); + g(["config", "core.fsmonitor", scripts[2]]); + return { dir: dir, markers: markers, scripts: scripts, git: g, hash: g(["rev-parse", "HEAD"]).trim() }; +} + +test("a repository whose config names textconv, diff.external and fsmonitor programs never runs them during history, diff or file-at", function () { + var repo = hostileRepo(); + + // The repository is hostile: git run without the hardening does run its programs. + repo.git(["diff", "HEAD~1", "HEAD"]); + repo.git(["diff", "--no-ext-diff", "--textconv", "HEAD~1", "HEAD"]); + repo.git(["status"]); + assert.deepEqual(fs.readdirSync(repo.markers).sort(), ["external", "fsmonitor", "textconv"], "the control run fires the configured programs"); + fs.readdirSync(repo.markers).forEach(function (m) { fs.unlinkSync(path.join(repo.markers, m)); }); + + var calls = [ + ["log", "--format=%H|%at|%an|%s", "--follow", "--", "f.txt"], + ["diff", repo.hash + "~1", repo.hash, "--", "f.txt"], + ["show", repo.hash, "--format=", "--", "f.txt"], + ["show", repo.hash + ":f.txt"], + ["status"], + ]; + calls.forEach(function (args) { + projectGit.runGit(args, { cwd: repo.dir }); + }); + assert.deepEqual(fs.readdirSync(repo.markers), [], "no program named by the repository's config ran"); +}); + +test("the same hostile repository stays inert through the WebSocket handlers", function () { + var repo = hostileRepo(); + var handlers = attachFilesystem({ + cwd: repo.dir, slug: "mine", osUsers: false, + sm: { sessions: new Map(), readSessionHistoryFromDisk: function () { return []; } }, + send: function () {}, sendTo: function (ws, obj) { ws.sent.push(obj); }, + fileAccess: createFileAccess({ + osUsers: false, usersModule: usersModule, fsAsUser: function () {}, + getOsUserInfoForWs: function () { return null; }, binaryExts: new Set(), maxSize: 512 * 1024, + }), + getOsUserInfoForWs: function () { return null; }, + startFileWatch: function () {}, stopFileWatch: function () {}, startDirWatch: function () {}, + usersModule: usersModule, fsAsUser: function () {}, validateEnvString: validateEnvString, + projectAccess: createProjectAccess({ users: usersModule, onGetProjectAccess: null, isMultiUser: function () { return false; } }), + opts: {}, IGNORED_DIRS: new Set([".git"]), IMAGE_EXTS: new Set(), + }); + var ws = fakeSocket(null); + ["fs_file_history", "fs_git_diff", "fs_file_at"].forEach(function (type) { + handlers.handleFilesystemMessage(ws, { type: type, path: "f.txt", hash: repo.hash }); + }); + var diff = ws.sent.filter(function (m) { return m.type === "fs_git_diff_result"; })[0]; + assert.match(diff.diff, /\+two/, "the diff is still produced from the committed blobs"); + assert.deepEqual(fs.readdirSync(repo.markers), []); +}); + +test("as another user: git runs as the repository's owner and its config programs stay inert", function (t) { + if (typeof process.getuid !== "function" || process.getuid() !== 0) { + t.skip("needs root to run git as another uid"); + return; + } + var repo = hostileRepo(); + var OWNER = 65534; + execFileSync("chown", ["-R", OWNER + ":" + OWNER, repo.dir, repo.markers]); + fs.chmodSync(SANDBOX, 0o755); + // The daemon (root) does not own this repository; git would refuse it as + // "dubious ownership" unless it runs as the owner. + assert.throws(function () { projectGit.runGit(["log", "-1"], { cwd: repo.dir, osUsers: false }); }); + var out = projectGit.runGit(["log", "--format=%s", "-1"], { cwd: repo.dir, osUsers: true }); + assert.match(out, /second/); + projectGit.runGit(["diff", repo.hash + "~1", repo.hash, "--", "f.txt"], { cwd: repo.dir, osUsers: true }); + projectGit.runGit(["status"], { cwd: repo.dir, osUsers: true }); + assert.deepEqual(fs.readdirSync(repo.markers), [], "no marker was created by any program"); +}); + +test("as another user: a project in a repository subdirectory still reads its history as the repository's owner", function (t) { + if (typeof process.getuid !== "function" || process.getuid() !== 0) { + t.skip("needs root to run git as another uid"); + return; + } + var repo = hostileRepo(); + var sub = path.join(repo.dir, "pkg"); + fs.mkdirSync(sub); + var OWNER = 65534; + execFileSync("chown", ["-R", OWNER + ":" + OWNER, repo.dir, repo.markers]); + fs.chmodSync(SANDBOX, 0o755); + var out = projectGit.runGit(["log", "--format=%s", "-1"], { cwd: sub, osUsers: true }); + assert.match(out, /second/); + assert.deepEqual(fs.readdirSync(repo.markers), [], "no marker was created by any program"); +}); diff --git a/test/project-loop-message-lr-4a9c.test.js b/test/project-loop-message-lr-4a9c.test.js index 4f913393..85d7f77f 100644 --- a/test/project-loop-message-lr-4a9c.test.js +++ b/test/project-loop-message-lr-4a9c.test.js @@ -86,7 +86,7 @@ function makeEngine(cwd, ctxOpts) { ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop", "../lib/loop-handoff"] .forEach(function(m) { - try { delete require.cache[require.resolve(m)]; } catch(_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; diff --git a/test/project-loop-message-lr-7025.test.js b/test/project-loop-message-lr-7025.test.js index dc6080be..0768afdb 100644 --- a/test/project-loop-message-lr-7025.test.js +++ b/test/project-loop-message-lr-7025.test.js @@ -74,7 +74,7 @@ function makeEngine(cwd, ctxOpts) { ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop", "../lib/loop-handoff"] .forEach(function(m) { - try { delete require.cache[require.resolve(m)]; } catch(_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; diff --git a/test/project-loop-message-lr-e31b.test.js b/test/project-loop-message-lr-e31b.test.js index 40a20525..9e6f5e8e 100644 --- a/test/project-loop-message-lr-e31b.test.js +++ b/test/project-loop-message-lr-e31b.test.js @@ -104,7 +104,7 @@ function makeEngine(cwd, ctxOpts) { ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop", "../lib/loop-handoff"] .forEach(function(m) { - try { delete require.cache[require.resolve(m)]; } catch(_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; diff --git a/test/project-loop-stop-race-lr-e823.test.js b/test/project-loop-stop-race-lr-e823.test.js index d5b6df54..574c6822 100644 --- a/test/project-loop-stop-race-lr-e823.test.js +++ b/test/project-loop-stop-race-lr-e823.test.js @@ -78,7 +78,7 @@ function makeEngine(cwd) { // Bust config cache so the fresh CLAGENTIC_HOME is picked up. ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop"] .forEach(function(m) { - try { delete require.cache[require.resolve(m)]; } catch(_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; diff --git a/test/prompt-hostile-request-id.test.js b/test/prompt-hostile-request-id.test.js index 7abcb94b..448afc85 100644 --- a/test/prompt-hostile-request-id.test.js +++ b/test/prompt-hostile-request-id.test.js @@ -142,19 +142,26 @@ test("an inherited name is no prompt on the push notification's HTTP route: 404, test("an inherited name is no prompt on the browser extension's result path: no throw, nothing settled", async function () { await withServer(async function (server) { var real = openOneOfEach(server); - var userMessages = attachUserMessage({ sm: server.sm, clients: server.clients, opts: {} }); + // The socket the extension commands were sent to. Which request ids it may + // answer is the project's concern (test/extension-result-binding.test.js); + // here every id reaches the registry so its handling of inherited names is what is tested. + var extensionSocket = { readyState: 1 }; + var userMessages = attachUserMessage({ + sm: server.sm, clients: server.clients, opts: {}, + mayAnswerExtensionCommand: function (requestId, ws) { return ws === extensionSocket; }, + }); var ids = HOSTILE_IDS.concat(NON_STRING_IDS); for (var i = 0; i < ids.length; i++) { var id = ids[i]; assert.doesNotThrow(function () { - userMessages.handleUserMessage({ readyState: 1 }, { type: "extension_result", requestId: id, result: { ok: true } }); + userMessages.handleUserMessage(extensionSocket, { type: "extension_result", requestId: id, result: { ok: true } }); }, "extension_result with requestId " + JSON.stringify(id)); } await world.flush(); assert.deepEqual(real.settled, {}, "nothing settled"); assertPrototypesClean(); - userMessages.handleUserMessage({ readyState: 1 }, { type: "extension_result", requestId: real.ids.extension, result: { ok: true } }); + userMessages.handleUserMessage(extensionSocket, { type: "extension_result", requestId: real.ids.extension, result: { ok: true } }); assert.deepEqual(await real.opened.extension.answer, { ok: true }, "the real extension prompt is still answerable"); }); }); diff --git a/test/prompt-registry.test.js b/test/prompt-registry.test.js index c688f3da..1e3d4f97 100644 --- a/test/prompt-registry.test.js +++ b/test/prompt-registry.test.js @@ -64,6 +64,12 @@ var KIND_OPENERS = { }, }; +// A table keyed by ids from outside has no prototype and no entries left. +function assertEmptyTable(table) { + assert.equal(Object.getPrototypeOf(table), null, "the table has no prototype"); + assert.deepEqual(Object.getOwnPropertyNames(table), []); +} + function eventsOf(h, type) { return h.recorded.filter(function (m) { return m.type === type; }); } @@ -118,7 +124,7 @@ test("an answer settles exactly once; a second answer is stale and still retires assert.equal(second.status, "stale"); assert.deepEqual(await opened.answer, { behavior: "allow", updatedInput: { command: "make" } }); assert.deepEqual(eventsOf(h, "prompt_resolved"), [{ type: "prompt_resolved", requestId: opened.requestId, kind: "permission", decision: "allow" }]); - assert.deepEqual(s.pendingPermissions, {}); + assertEmptyTable(s.pendingPermissions); assert.equal(h.index[opened.requestId], undefined); assert.deepEqual(h.dismissed, [opened.requestId, opened.requestId]); }); @@ -398,8 +404,8 @@ test("query end ends only that query's prompts; an owner also ends unstamped one assert.equal(s.activeTaskToolIds["task-1"], true, "a superseded query does not touch the session's Task tracking"); h.registry.endQuery(s, newQuery, true); - assert.deepEqual(s.pendingPermissions, {}); - assert.deepEqual(s.activeTaskToolIds, {}); + assertEmptyTable(s.pendingPermissions); + assertEmptyTable(s.activeTaskToolIds); assert.ok(eventsOf(h, "prompt_cancel").every(function (m) { return m.reason === "query_ended"; })); }); diff --git a/test/prototype-free-tables.test.js b/test/prototype-free-tables.test.js new file mode 100644 index 00000000..2815d19e --- /dev/null +++ b/test/prototype-free-tables.test.js @@ -0,0 +1,68 @@ +// Sweep guard: the tables below are keyed by names and ids that arrive from +// outside (a worker, the app-server, an MCP server, a file name, a server +// message). Each must be a prototype-less map, never a plain {} that answers +// for "constructor" or "__proto__". A new assignment of {} to one of them +// fails here; the behaviour is covered where the table can be driven +// (test/hostile-keys-stores.test.js, test/client-supplied-keys.test.js). + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); + +var ROOT = path.resolve(__dirname, ".."); + +var TABLES = { + "lib/yoke/adapters/claude-worker.js": ["pendingPermissions", "pendingAskUser", "pendingElicitations", "pendingMcpToolCalls"], + "lib/yoke/adapters/claude.js": ["pendingRewinds", "pendingSetModel", "promptAborts"], + "lib/yoke/codex-app-server.js": ["pendingRequests"], + "lib/mcp-local.js": ["_configCache", "_processes", "_pendingRequests", "_initCallbacks"], + "lib/project-mcp.js": ["_availableServers", "_proxyServers", "_pendingCalls"], + "lib/public/modules/filebrowser.js": ["treeData", "gitDiffCache", "fileAtCache", "expandedSet", "tree", "oldSet", "newSet"], + "lib/public/modules/app-cursors.js": ["remoteCursors", "userColorMap", "remoteSelections"], + "lib/public/modules/sticky-notes.js": ["updateTimers", "textTimers"], + "lib/public/modules/team-panel.js": ["_memberStatuses", "_memberActivities"], + "lib/public/modules/app-messages.js": ["handlers"], +}; + +function plainLiteral(name) { + return new RegExp("\\b" + name + "\\s*(?:=\\s*(?:[^;=\\n]*\\|\\|\\s*)?|:\\s*)\\{"); +} + +test("the sweep recognises every way a table can be given a plain object", function () { + [ + "var handlers = {};", + "var handlers = { a: f };", + "var handlers = opts.handlers || {};", + "handlers = x || { a: 1 };", + "return { handlers: {} };", + "return { handlers: { a: f } };", + "var o = { handlers:{} };", + ].forEach(function (source) { + assert.equal(plainLiteral("handlers").test(source), true, source); + }); + [ + "var handlers = Object.create(null);", + "var handlers = opts.handlers || Object.create(null);", + "handlers[key] = { a: 1 };", + "var other = {};", + ].forEach(function (source) { + assert.equal(plainLiteral("handlers").test(source), false, source); + }); +}); + +Object.keys(TABLES).forEach(function (file) { + test(file + ": its tables keyed by outside names are prototype-free", function () { + var source = fs.readFileSync(path.join(ROOT, file), "utf8"); + TABLES[file].forEach(function (name) { + // Any object literal is a plain object, empty or not: `name = {}`, + // `name = x || {}`, `name = { a: f }`, and a property `name: {}` or + // `name: { a: f }` in a returned or passed object. + assert.equal(plainLiteral(name).test(source), false, name + " is given a plain object literal"); + var safe = new RegExp("\\b" + name + "\\s*=\\s*(?:[^;=\\n]*\\|\\|\\s*)?Object\\.create\\(null\\)"); + assert.equal(safe.test(source), true, name + " is never made with Object.create(null)"); + }); + }); +}); diff --git a/test/require-cache-reset-guard.test.js b/test/require-cache-reset-guard.test.js new file mode 100644 index 00000000..fbb8e151 --- /dev/null +++ b/test/require-cache-reset-guard.test.js @@ -0,0 +1,214 @@ +// A test that clears modules from the require cache must fail when one of the +// modules it names no longer resolves. An empty catch around require.resolve +// hid a renamed or removed module and left the stale cached copy in place, so +// the test ran against old code without saying so. +// +// Two checks, over every .js file under test/ (subdirectories included): +// 1. no try block that calls require.resolve, however deeply its body nests, +// is followed by a catch that does nothing; +// 2. every relative module a require-cache reset names resolves, whether it +// is written as require.resolve("./x"), in a list passed to forEach, or +// in a list held in a variable, with either quote style. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); + +var TEST_DIR = __dirname; +var SELF = path.join(TEST_DIR, path.basename(__filename)); + +function testFiles(dir) { + var out = []; + fs.readdirSync(dir, { withFileTypes: true }).forEach(function (entry) { + var full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== "node_modules") out = out.concat(testFiles(full)); + } else if (/\.js$/.test(entry.name) && full !== SELF) { + out.push(full); + } + }); + return out; +} + +// Words after which a "/" starts a regular expression rather than a division. +var REGEX_AFTER_WORD = /^(?:return|typeof|case|in|of|delete|void|throw|new|else|do|instanceof|yield|await)$/; +var REGEX_AFTER_CHAR = "(,=:[!&|?{};+-*%<>~^"; + +// Whether the "/" at `i` opens a regular expression literal, judged from the +// last significant token before it. A "/" after an identifier, number, ")" or +// "]" is a division. "}" is read as a division too, which misreads only a +// regex literal that starts a statement straight after a block. +function opensRegex(source, i) { + var j = i - 1; + while (j >= 0 && /\s/.test(source[j])) j--; + if (j < 0) return true; + if (REGEX_AFTER_CHAR.indexOf(source[j]) !== -1) return true; + var end = j + 1; + while (j >= 0 && /[A-Za-z0-9_$]/.test(source[j])) j--; + return REGEX_AFTER_WORD.test(source.slice(j + 1, end)); +} + +// Index of the "/" that closes the regex literal opened at `open`, or -1. +function regexEnd(source, open) { + var inClass = false; + for (var i = open + 1; i < source.length; i++) { + var c = source[i]; + if (c === "\n") return -1; + if (c === "\\") i++; + else if (c === "[") inClass = true; + else if (c === "]") inClass = false; + else if (c === "/" && !inClass) return i; + } + return -1; +} + +// Index of the brace that closes the one opened at `open`, skipping string +// literals, regular expression literals and comments, or -1 when it never +// closes. Not parsed: code nested inside a template literal's ${...}, so a +// quote or brace written there can still throw the count off. +function closingBrace(source, open) { + var depth = 0; + for (var i = open; i < source.length; i++) { + var c = source[i]; + var next = source[i + 1]; + if (c === "/" && next === "/") { + var eol = source.indexOf("\n", i); + if (eol === -1) return -1; + i = eol; + } else if (c === "/" && next === "*") { + var end = source.indexOf("*/", i + 2); + if (end === -1) return -1; + i = end + 1; + } else if (c === "/" && opensRegex(source, i)) { + var close = regexEnd(source, i); + if (close === -1) return -1; + i = close; + } else if (c === '"' || c === "'" || c === "`") { + for (i = i + 1; i < source.length && source[i] !== c; i++) { + if (source[i] === "\\") i++; + } + } else if (c === "{") { + depth++; + } else if (c === "}") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +// A catch body that does nothing: only comments, empty statements (";") and +// the no-op expression "void 0;". +function isEmptyBody(text) { + var code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); + return code.replace(/\bvoid\s+0\b/g, "").replace(/[;\s]/g, "") === ""; +} + +// The try statements in `source` that call require.resolve and whose catch is empty. +function emptyCatchesAroundResolve(source) { + var found = []; + var tryRe = /\btry\s*\{/g; + var m; + while ((m = tryRe.exec(source)) !== null) { + var open = m.index + m[0].length - 1; + var close = closingBrace(source, open); + if (close === -1) continue; + var body = source.slice(open + 1, close); + if (body.indexOf("require.resolve") === -1) continue; + var after = /^\s*catch\s*(?:\([^)]*\))?\s*\{/.exec(source.slice(close + 1)); + if (!after) continue; + var catchOpen = close + 1 + after[0].length - 1; + var catchClose = closingBrace(source, catchOpen); + if (catchClose === -1) continue; + if (isEmptyBody(source.slice(catchOpen + 1, catchClose))) { + found.push(source.slice(m.index, catchClose + 1).replace(/\s+/g, " ").slice(0, 100)); + } + } + return found; +} + +// Relative module names a require-cache reset in `source` names. +function namedModules(source) { + var names = []; + var m; + var literal = /require\.resolve\(\s*(["'])(\.{1,2}\/[^"']+)\1\s*\)/g; + while ((m = literal.exec(source)) !== null) names.push(m[2]); + if (source.indexOf("require.cache") !== -1) { + // Any array of relative module names in a file that resets the cache, + // whether it feeds forEach directly or is held in a variable first. + var list = /\[\s*((?:(["'])\.{1,2}\/[^"']+\2\s*,?\s*)+)\]/g; + while ((m = list.exec(source)) !== null) { + var entry = /(["'])(\.{1,2}\/[^"']+)\1/g; + var e; + while ((e = entry.exec(m[1])) !== null) names.push(e[2]); + } + } + return names; +} + +test("the guard recognises the pattern it forbids", function () { + var bad = [ + 'try { delete require.cache[require.resolve(m)]; } catch (_) {}', + 'try { delete require.cache[require.resolve(m)]; } catch(_) {}', + 'try { delete require.cache[require.resolve("../lib/x")]; } catch (e) { /* ignore */ }', + 'try {\n delete require.cache[require.resolve(m)];\n} catch (_) {\n}', + 'try { if (fresh) { delete require.cache[require.resolve(m)]; } } catch (_) {}', + 'try {\n forEach(function (n) { if (n) { delete require.cache[require.resolve(n)]; } });\n} catch {\n // nothing\n}', + "try { const s = '}'; delete require.cache[require.resolve(m)]; } catch (_) {}", + 'try { var q = /"/; delete require.cache[require.resolve(m)]; } catch (_) {}', + "try { var b = /}/.test(x); delete require.cache[require.resolve(m)]; } catch (_) {}", + "try { var c = /[}'\"]/g; delete require.cache[require.resolve(m)]; } catch (_) {}", + "try { return /{/.test(x) && require.resolve(m); } catch (_) {}", + 'try { delete require.cache[require.resolve(m)]; } catch (_) { ; }', + 'try { delete require.cache[require.resolve(m)]; } catch (_) { void 0; }', + 'try { delete require.cache[require.resolve(m)]; } catch (_) { /* x */ ; void 0; ; }', + ]; + bad.forEach(function (source) { + assert.equal(emptyCatchesAroundResolve(source).length, 1, source); + }); + var fine = [ + 'delete require.cache[require.resolve(m)];', + 'try { delete require.cache[require.resolve(m)]; } catch (e) { throw new Error("stale " + m); }', + 'try { if (x) { fs.rmSync(dir, { recursive: true }); } } catch (_) {}', + 'try { if (x) { delete require.cache[require.resolve(m)]; } } catch (e) { if (e) { throw e; } }', + 'try { var half = total / 2; delete require.cache[require.resolve(m)]; } catch (e) { log(half / 2); }', + 'try { delete require.cache[require.resolve(m)]; } catch (_) { void 1; }', + ]; + fine.forEach(function (source) { + assert.deepEqual(emptyCatchesAroundResolve(source), [], source); + }); +}); + +test("the guard finds every way a reset names a module", function () { + assert.deepEqual(namedModules('delete require.cache[require.resolve("../lib/a")]; require.resolve(\'./b\')'), ["../lib/a", "./b"]); + assert.deepEqual(namedModules('["../lib/a", \'../lib/b\', "./c"].forEach(f); require.cache'), ["../lib/a", "../lib/b", "./c"]); + assert.deepEqual(namedModules('var MODULES = [\n "../lib/a",\n \'./b\',\n];\nMODULES.forEach(reset); require.cache'), ["../lib/a", "./b"]); + assert.deepEqual(namedModules('var NAMES = ["../lib/a"];'), [], "a list in a file that never touches the cache is not a reset"); +}); + +test("no test swallows a failed require.resolve in a require-cache reset", function () { + var offenders = []; + testFiles(TEST_DIR).forEach(function (file) { + emptyCatchesAroundResolve(fs.readFileSync(file, "utf8")).forEach(function (text) { + offenders.push(path.relative(TEST_DIR, file) + ": " + text); + }); + }); + assert.deepEqual(offenders, [], "a missing module must fail the test, not be skipped"); +}); + +test("every module a require-cache reset names resolves", function () { + var unresolved = []; + testFiles(TEST_DIR).forEach(function (file) { + namedModules(fs.readFileSync(file, "utf8")).forEach(function (name) { + try { + require.resolve(path.resolve(path.dirname(file), name)); + } catch (e) { + unresolved.push(path.relative(TEST_DIR, file) + ": " + name); + } + }); + }); + assert.deepEqual(unresolved, []); +}); diff --git a/test/server-cross-project-unread-per-session-lr-0aa7b6.test.js b/test/server-cross-project-unread-per-session-lr-0aa7b6.test.js index 6d658cc5..7fdeec79 100644 --- a/test/server-cross-project-unread-per-session-lr-0aa7b6.test.js +++ b/test/server-cross-project-unread-per-session-lr-0aa7b6.test.js @@ -75,7 +75,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, onSessionDone) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -345,7 +345,7 @@ function makeEngine(cwd, sessionsByProject, unreadBySessKey) { var tmpHome = makeTempHomeLoop(); ["../lib/config", "../lib/utils", "../lib/store", "../lib/scheduler", "../lib/project-loop", "../lib/loop-handoff"] .forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-auto-title-persist-lr-62157d.test.js b/test/session-auto-title-persist-lr-62157d.test.js index 9e56da84..a899d181 100644 --- a/test/session-auto-title-persist-lr-62157d.test.js +++ b/test/session-auto-title-persist-lr-62157d.test.js @@ -28,7 +28,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js b/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js index d4dace04..40855418 100644 --- a/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js +++ b/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js @@ -31,7 +31,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, sendSpy) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-history-memory-leak-lr-2ea2a7.test.js b/test/session-history-memory-leak-lr-2ea2a7.test.js index c372f493..ce75012b 100644 --- a/test/session-history-memory-leak-lr-2ea2a7.test.js +++ b/test/session-history-memory-leak-lr-2ea2a7.test.js @@ -29,7 +29,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, extraOpts) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; @@ -152,7 +152,7 @@ test("lr-2ea2a7 soak: 5,000 events through an isProcessing session stays heap-bo function makeSessionManagerWithCapture(tmpHome, captured) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-lifecycle-lr-e0de.test.js b/test/session-lifecycle-lr-e0de.test.js index bac5d265..94cd05bb 100644 --- a/test/session-lifecycle-lr-e0de.test.js +++ b/test/session-lifecycle-lr-e0de.test.js @@ -30,7 +30,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { // Bust require cache so a fresh instance picks up the temp CLAGENTIC_HOME. ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function(m) { - try { delete require.cache[require.resolve(m)]; } catch(_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-meta-only-save-loaded-lr-f940.test.js b/test/session-meta-only-save-loaded-lr-f940.test.js index 8be75b73..6e206c56 100644 --- a/test/session-meta-only-save-loaded-lr-f940.test.js +++ b/test/session-meta-only-save-loaded-lr-f940.test.js @@ -35,7 +35,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-meta-rewrite-lr-79c6.test.js b/test/session-meta-rewrite-lr-79c6.test.js index edf3871e..d90d6987 100644 --- a/test/session-meta-rewrite-lr-79c6.test.js +++ b/test/session-meta-rewrite-lr-79c6.test.js @@ -24,7 +24,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function(m) { - try { delete require.cache[require.resolve(m)]; } catch(_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-model-persist-restart-lr-db0437.test.js b/test/session-model-persist-restart-lr-db0437.test.js index 40fe5847..4cc3f6a5 100644 --- a/test/session-model-persist-restart-lr-db0437.test.js +++ b/test/session-model-persist-restart-lr-db0437.test.js @@ -29,7 +29,7 @@ function makeTempHome() { function freshSessionsModule(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-rewind-fork-baseindex-lr-2ea2a7.test.js b/test/session-rewind-fork-baseindex-lr-2ea2a7.test.js index 93ab9b7e..2cc7a1b4 100644 --- a/test/session-rewind-fork-baseindex-lr-2ea2a7.test.js +++ b/test/session-rewind-fork-baseindex-lr-2ea2a7.test.js @@ -26,7 +26,7 @@ function makeTempHome() { function makeSessionManager(tmpHome, extraOpts) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/session-search-streaming-lr-2ea2a7.test.js b/test/session-search-streaming-lr-2ea2a7.test.js index 3c9972b6..730b4b18 100644 --- a/test/session-search-streaming-lr-2ea2a7.test.js +++ b/test/session-search-streaming-lr-2ea2a7.test.js @@ -23,7 +23,7 @@ function makeTempHome() { function makeSessionManager(tmpHome) { ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/single-user-null-user.test.js b/test/single-user-null-user.test.js new file mode 100644 index 00000000..68f52aec --- /dev/null +++ b/test/single-user-null-user.test.js @@ -0,0 +1,218 @@ +// The single-user owner has no user record: a connection the login gate lets +// through carries _clagenticUser === null and is the implicit owner. This +// drives a real server with a real WebSocket and HTTP client through every +// path the access gate touches, once with the daemon in single-user mode (the +// owner must see no regression anywhere) and once in multi-user mode (a +// request with no user is a stranger and is refused, never an exception). +// +// The login gate itself is stood in for: it admits every request and names no +// user, which is the state the rest of the server must handle. Everything past +// the gate is real code. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); +var { execFileSync } = require("child_process"); +var harness = require("./access-harness"); + +var H = null; +var mode = { multiUser: false }; +var HASH = null; + +function projectCtx(slug) { + var found = null; + H.relay.forEachProject(function (ctx, s) { if (s === slug) found = ctx; }); + return found; +} + +function ask(client, msg, type) { + client.inbox.length = 0; + client.send(msg); + return client.waitFor(function (m) { return m.type === type; }); +} + +function git(dir, args) { + return execFileSync("git", args, { + cwd: dir, encoding: "utf8", + env: Object.assign({}, process.env, { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }), + }); +} + +test.before(async function () { + H = await harness.start({ + multiUser: false, + users: [], + projects: [ + { slug: "mine", files: { "a.png": "png", "notes.txt": "one\n" } }, + { slug: "other" }, + { slug: "mine--wt", parent: "mine" }, + ], + serverOpts: { isMultiUser: function () { return mode.multiUser; } }, + beforeServer: function () { + var serverAuth = require("../lib/server-auth"); + var attach = serverAuth.attachAuth; + serverAuth.attachAuth = function (deps) { + return Object.assign({}, attach(deps), { + getMultiUserFromReq: function () { return null; }, + isRequestAuthed: function () { return true; }, + }); + }; + }, + }); + var dir = H.dirs.mine; + git(dir, ["init", "-q"]); + git(dir, ["add", "notes.txt"]); + git(dir, ["commit", "-q", "-m", "first"]); + fs.writeFileSync(path.join(dir, "notes.txt"), "one\ntwo\n"); + git(dir, ["commit", "-q", "-am", "second"]); + HASH = git(dir, ["rev-parse", "HEAD"]).trim(); +}); + +test.after(async function () { + if (H) await H.stop(); +}); + +// --- single-user mode: the owner loses nothing ---------------------------------- + +test("single-user: the WebSocket upgrade admits the owner into every project, worktrees included", async function () { + mode.multiUser = false; + var mine = await H.connect("mine", null); + assert.ok(mine.ok, "mine"); + assert.ok((await H.connect("other", null)).ok, "other"); + assert.ok((await H.connect("mine--wt", null)).ok, "a worktree slug"); + var info = await mine.waitFor(function (m) { return m.type === "info"; }); + assert.deepEqual(info.projects.map(function (p) { return p.slug; }).sort(), ["mine", "mine--wt", "other"], "the project list is the full list"); +}); + +test("single-user: global CLAUDE.md, shared env and project env work for the owner", async function () { + mode.multiUser = false; + var c = await H.connect("mine", null); + assert.equal((await ask(c, { type: "write_global_claude_md", content: "# owner" }, "write_global_claude_md_result")).ok, true); + assert.equal((await ask(c, { type: "read_global_claude_md" }, "global_claude_md_result")).content, "# owner"); + assert.ok(await ask(c, { type: "get_shared_env" }, "shared_env_result")); + var shared = await ask(c, { type: "set_shared_env", envrc: "A=1" }, "set_shared_env_result"); + assert.doesNotMatch(String(shared.error), /Admin access/); + var env = await ask(c, { type: "get_project_env", slug: "someone-elses" }, "project_env_result"); + assert.equal(env.slug, "mine", "the env of the connected project, whatever slug the message carries"); +}); + +test("single-user: files, search, history, diff and file-at work for the owner", async function () { + mode.multiUser = false; + var c = await H.connect("mine", null); + var listing = await ask(c, { type: "fs_list", path: "." }, "fs_list_result"); + assert.deepEqual(listing.entries.map(function (e) { return e.name; }).sort(), ["a.png", "notes.txt"]); + assert.equal((await ask(c, { type: "fs_read", path: "notes.txt" }, "fs_read_result")).content, "one\ntwo\n"); + assert.equal((await ask(c, { type: "fs_search", query: "notes" }, "fs_search_result")).entries[0].path, "notes.txt"); + var history = await ask(c, { type: "fs_file_history", path: "notes.txt" }, "fs_file_history_result"); + assert.equal(history.entries.filter(function (e) { return e.source === "git"; }).length, 2); + assert.match((await ask(c, { type: "fs_git_diff", path: "notes.txt", hash: HASH }, "fs_git_diff_result")).diff, /\+two/); + assert.equal((await ask(c, { type: "fs_file_at", path: "notes.txt", hash: HASH }, "fs_file_at_result")).content, "one\ntwo\n"); +}); + +test("single-user: a message naming another project is handled there, with and without targetSlug", async function () { + mode.multiUser = false; + var c = await H.connect("mine", null); + assert.equal((await ask(c, { type: "get_project_env", targetSlug: "other" }, "project_env_result")).slug, "other"); + assert.equal((await ask(c, { type: "get_project_env", targetSlug: "mine" }, "project_env_result")).slug, "mine"); + assert.equal((await ask(c, { type: "get_project_env" }, "project_env_result")).slug, "mine"); + var gone = await ask(c, { type: "get_project_env", targetSlug: "nowhere" }, "error"); + assert.match(gone.text, /not found or access denied/); +}); + +test("single-user: sessions of every owner can be renamed, searched and deleted by the owner", async function () { + mode.multiUser = false; + var sm = projectCtx("mine").sm; + var theirs = sm.createSessionRaw({ ownerId: "a-former-user", sessionVisibility: "private" }); + theirs.title = "legacy-private-session"; + var c = await H.connect("mine", null); + c.send({ type: "rename_session", id: theirs.localId, title: "owner-renamed" }); + await harness.settle(200); + assert.equal(sm.sessions.get(theirs.localId).title, "owner-renamed"); + var found = await ask(c, { type: "search_sessions", query: "owner-renamed" }, "search_results"); + assert.equal(found.results.length, 1); + var content = await ask(c, { type: "search_session_content", id: theirs.localId, query: { not: "text" } }, "search_content_results"); + assert.equal(content.query, "", "a query that is not text is neither searched nor echoed"); + c.send({ type: "delete_session", id: theirs.localId }); + await harness.settle(200); + assert.equal(sm.sessions.has(theirs.localId), false); +}); + +test("single-user: the project routes, root redirect, image route and palette serve the owner", async function () { + mode.multiUser = false; + assert.equal((await H.request("GET", "/p/mine/", null)).status, 200); + assert.equal((await H.request("GET", "/p/mine--wt/", null)).status, 200); + var root = await H.request("GET", "/", null); + assert.equal(root.status, 302); + assert.match(root.headers.location, /^\/p\/[a-z-]+\/$/); + var image = await H.request("GET", "/p/mine/api/file?path=a.png", null); + assert.equal(image.status, 200); + assert.equal(image.headers["content-type"], "image/png"); + + var s = projectCtx("other").sm.createSessionRaw({ ownerId: "a-former-user", sessionVisibility: "private" }); + s.title = "palette-owner-sees-this"; + var palette = await H.request("GET", "/api/palette/search?q=palette-owner", null); + assert.equal(palette.status, 200); + assert.deepEqual(JSON.parse(palette.body).results.map(function (r) { return r.sessionTitle; }), ["palette-owner-sees-this"]); + var recent = JSON.parse((await H.request("GET", "/api/palette/search", null)).body).results; + assert.ok(recent.some(function (r) { return r.sessionTitle === "palette-owner-sees-this"; })); +}); + +test("single-user: project list broadcasts and renames reach the owner with every project", async function () { + mode.multiUser = false; + var c = await H.connect("mine", null); + H.relay.broadcastAll({ type: "projects_updated", marker: "su-list", projects: H.relay.getProjects(), projectCount: 99 }); + var update = await c.waitFor(function (m) { return m.marker === "su-list"; }); + assert.equal(update.projects.length, 3); + assert.equal(update.projectCount, 3); + H.relay.setProjectTitle("mine", "Renamed Mine"); + var info = await c.waitFor(function (m) { return m.type === "info" && m.project === "Renamed Mine"; }); + assert.equal(info.projects.length, 3); +}); + +test("single-user: the local MCP bridge still answers with no login", async function () { + mode.multiUser = false; + var bridge = await H.request("POST", "/p/mine/api/mcp-bridge", null, { action: "no-such-action" }); + assert.equal(bridge.status, 400); +}); + +// --- multi-user mode: no user is a stranger, and nothing throws ------------------- + +test("multi-user: a request with no user is refused on every path and the server keeps serving", async function () { + mode.multiUser = true; + try { + assert.equal((await H.connect("mine", null)).status, 401, "WS upgrade"); + assert.equal((await H.connect("mine--wt", null)).status, 401, "WS upgrade, worktree"); + assert.equal((await H.connect("no-such-project", null)).status, 401, "WS upgrade, unknown project"); + assert.equal((await H.request("GET", "/p/mine/", null)).status, 302, "/p/"); + assert.equal((await H.request("GET", "/p/mine/", null)).headers.location, "/"); + assert.equal((await H.request("GET", "/p/mine/api/file?path=a.png", null)).status, 302, "/api/file"); + assert.equal((await H.request("GET", "/api/palette/search", null)).status, 401, "palette"); + assert.equal((await H.request("GET", "/api/palette/search?q=x", null)).status, 401, "palette search"); + var root = await H.request("GET", "/", null); + assert.equal(root.status, 200, "the root never redirects to a project for a stranger"); + assert.equal((await H.request("GET", "/api/health", null)).status, 200, "the server is still up"); + } finally { + mode.multiUser = false; + } +}); + +test("multi-user: a connection that was admitted in single-user mode is refused once the mode is multi-user", async function () { + mode.multiUser = false; + var c = await H.connect("mine", null); + assert.equal((await ask(c, { type: "get_project_env" }, "project_env_result")).slug, "mine"); + mode.multiUser = true; + try { + var refused = await ask(c, { type: "fs_read", path: "notes.txt" }, "error"); + assert.match(refused.text, /not found or access denied/); + var settings = await ask(c, { type: "read_global_claude_md" }, "error"); + assert.match(settings.text, /not found or access denied/); + H.relay.broadcastAll({ type: "projects_updated", marker: "mu-list", projects: H.relay.getProjects(), projectCount: 99 }); + var update = await c.waitFor(function (m) { return m.marker === "mu-list"; }); + assert.deepEqual(update.projects, [], "a stranger is sent no projects"); + } finally { + mode.multiUser = false; + } +}); diff --git a/test/switch-session-noauth-lr-690b.test.js b/test/switch-session-noauth-lr-690b.test.js index bb41dc4b..c3770746 100644 --- a/test/switch-session-noauth-lr-690b.test.js +++ b/test/switch-session-noauth-lr-690b.test.js @@ -28,7 +28,7 @@ function makeSessionManager(tmpHome, extraOpts) { ["../lib/config", "../lib/sessions", "../lib/users", "../lib/utils", "../lib/store", "../lib/users-auth", "../lib/users-permissions", "../lib/users-preferences"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome; diff --git a/test/ws-origin.test.js b/test/ws-origin.test.js new file mode 100644 index 00000000..fab5e9f7 --- /dev/null +++ b/test/ws-origin.test.js @@ -0,0 +1,82 @@ +// WebSocket upgrade origin check (lib/ws-origin.js): the full origin, not just +// the port, must belong to the daemon. One test per caller shape. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var { checkWsOrigin } = require("../lib/ws-origin"); + +function check(headers, extra) { + return checkWsOrigin(Object.assign({ headers: headers, tls: false, trustedProxy: false, allowedOrigins: [] }, extra || {})).ok; +} + +test("direct LAN access by IP and port", function () { + assert.equal(check({ origin: "http://192.168.1.20:2633", host: "192.168.1.20:2633" }), true); + assert.equal(check({ origin: "http://192.168.1.20:2633", host: "192.168.1.21:2633" }), false); + assert.equal(check({ origin: "http://192.168.1.20:9999", host: "192.168.1.20:2633" }), false, "same host, other port"); +}); + +test("the same port on a different host is refused", function () { + assert.equal(check({ origin: "http://evil.example:2633", host: "console.lan:2633" }), false); + assert.equal(check({ origin: "http://sibling.example.com:2633", host: "console.example.com:2633" }), false); +}); + +test("host names compare case-insensitively", function () { + assert.equal(check({ origin: "http://Console.LAN:2633", host: "console.lan:2633" }), true); +}); + +test("a TLS-terminating reverse proxy on :443 in front of an HTTP daemon", function () { + assert.equal(check({ origin: "https://console.example.com", host: "console.example.com" }), true); + assert.equal(check({ origin: "https://console.example.com:443", host: "console.example.com" }), true); + assert.equal(check({ origin: "https://other.example.com", host: "console.example.com" }), false, "a sibling subdomain"); +}); + +test("a proxy that rewrites Host is trusted only when the operator says so", function () { + var headers = { origin: "https://console.example.com", host: "127.0.0.1:2633", "x-forwarded-host": "console.example.com", "x-forwarded-proto": "https" }; + assert.equal(check(headers), false, "X-Forwarded-Host is ignored without trustedProxy"); + assert.equal(check(headers, { trustedProxy: true }), true); + assert.equal(check({ origin: "https://evil.example.com", host: "127.0.0.1:2633", "x-forwarded-host": "console.example.com" }, { trustedProxy: true }), false); +}); + +test("the scheme is compared only where the daemon knows it", function () { + assert.equal(check({ origin: "http://console.lan:2633", host: "console.lan:2633" }, { tls: true }), false, "a TLS daemon serves https origins only"); + assert.equal(check({ origin: "https://console.lan:2633", host: "console.lan:2633" }, { tls: true }), true); + assert.equal(check({ origin: "http://console.example.com", host: "console.example.com", "x-forwarded-proto": "https" }, { trustedProxy: true }), false); + assert.equal(check({ origin: "https://console.example.com", host: "console.example.com", "x-forwarded-proto": "https" }, { trustedProxy: true }), true); + assert.equal(check({ origin: "https://console.example.com", host: "console.example.com", "x-forwarded-proto": "https" }), true, "an unconfigured proxy's header is not consulted"); +}); + +test("IPv6 hosts", function () { + assert.equal(check({ origin: "http://[::1]:2633", host: "[::1]:2633" }), true); + assert.equal(check({ origin: "http://[::1]:2633", host: "[::2]:2633" }), false); + assert.equal(check({ origin: "https://[2001:db8::5]", host: "[2001:db8::5]" }), true); +}); + +test("clients with no Origin (CLI, relay, MCP bridge) are accepted", function () { + assert.equal(check({ host: "console.lan:2633" }), true); + assert.equal(check({}), true); + assert.equal(check({ origin: "", host: "console.lan:2633" }), true); +}); + +test("an Origin with no Host header is refused", function () { + assert.equal(check({ origin: "http://console.lan:2633" }), false); +}); + +test("origins that are not http(s) origins are refused: null, malformed, extension", function () { + assert.equal(check({ origin: "null", host: "console.lan:2633" }), false); + assert.equal(check({ origin: "not a url", host: "console.lan:2633" }), false); + assert.equal(check({ origin: "file:///tmp/x.html", host: "console.lan:2633" }), false); + assert.equal(check({ origin: "chrome-extension://abcdefghijklmnop", host: "console.lan:2633" }), false); + assert.equal(check({ origin: ["http://console.lan:2633"], host: "console.lan:2633" }), false); +}); + +test("the operator's allowedOrigins list admits what the host check would refuse", function () { + var allowed = ["https://console.example.com", "chrome-extension://abcdefghijklmnop"]; + assert.equal(check({ origin: "https://console.example.com", host: "127.0.0.1:2633" }, { allowedOrigins: allowed }), true); + assert.equal(check({ origin: "https://console.example.com:443", host: "127.0.0.1:2633" }, { allowedOrigins: allowed }), true, "default port made explicit"); + assert.equal(check({ origin: "chrome-extension://abcdefghijklmnop", host: "127.0.0.1:2633" }, { allowedOrigins: allowed }), true); + assert.equal(check({ origin: "chrome-extension://other", host: "127.0.0.1:2633" }, { allowedOrigins: allowed }), false); + assert.equal(check({ origin: "https://evil.example.com", host: "127.0.0.1:2633" }, { allowedOrigins: allowed }), false); + assert.equal(check({ origin: "https://console.example.com", host: "127.0.0.1:2633" }, { allowedOrigins: [5, null, {}] }), false); +}); diff --git a/test/ws-ticket-auth-lr-de5fcb.test.js b/test/ws-ticket-auth-lr-de5fcb.test.js index d3139241..eafc5bdb 100644 --- a/test/ws-ticket-auth-lr-de5fcb.test.js +++ b/test/ws-ticket-auth-lr-de5fcb.test.js @@ -35,7 +35,7 @@ function makeAuth(tmpHome) { ["../lib/config", "../lib/users", "../lib/users-auth", "../lib/users-permissions", "../lib/users-preferences", "../lib/store", "../lib/server-auth", "../lib/smtp", "../lib/pages"].forEach(function (m) { - try { delete require.cache[require.resolve(m)]; } catch (_) {} + delete require.cache[require.resolve(m)]; }); var origHome = process.env.CLAGENTIC_HOME; process.env.CLAGENTIC_HOME = tmpHome;