From 6a198dd2d974b2a2ed6b28b49436a0c370d0fce9 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 20:18:31 -0400 Subject: [PATCH 01/12] fix(access): gate every project message and fail access lookups closed (lr-783ba1) One access gate at the entry of project message handling authorizes the current project and any targetSlug before a handler runs. Access lookups fail closed everywhere (upgrade, HTTP route, lists, palette, schedules), a record with no visibility is private, worktrees take their parent's access, and project lists and schedules reach each client filtered. File access shares one policy across the WS handlers, the HTTP route and the watchers: fileBrowser permission on every fs_ message but fs_unwatch, no daemon-user fallback in os-users mode, hex-only git revisions, project-relative git paths run as the caller's OS identity, per-connection watches. Global CLAUDE.md and shared env are administrator-only; env messages act on the authorized slug. Session rename, delete and search check access to the session. The WebSocket origin check compares host and port against the request host, with an operator allow-list. Client-supplied vendor, tab and call ids no longer index plain objects, and extension and MCP results are accepted only from the socket the call went to. Co-Authored-By: Claude Sonnet 5.5 --- lib/daemon-projects.js | 38 ++++++ lib/daemon.js | 17 +-- lib/project-access.js | 65 ++++++++++ lib/project-connection.js | 11 +- lib/project-file-scope.js | 167 +++++++++++++++++++++++++ lib/project-file-watch.js | 147 ++++++++++++---------- lib/project-filesystem.js | 237 +++++++++++++++++------------------- lib/project-git.js | 45 +++++++ lib/project-http.js | 19 +-- lib/project-loop.js | 13 +- lib/project-mcp.js | 61 ++++++---- lib/project-message-gate.js | 59 +++++++++ lib/project-sessions.js | 41 +++++-- lib/project-user-message.js | 21 +++- lib/project.js | 154 ++++++++++++----------- lib/server-palette.js | 31 +++-- lib/server.js | 170 +++++++++++--------------- lib/sessions.js | 29 +++-- lib/users-permissions.js | 6 +- lib/ws-origin.js | 99 +++++++++++++++ lib/yoke/index.js | 9 ++ 21 files changed, 981 insertions(+), 458 deletions(-) create mode 100644 lib/project-access.js create mode 100644 lib/project-file-scope.js create mode 100644 lib/project-git.js create mode 100644 lib/project-message-gate.js create mode 100644 lib/ws-origin.js 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/project-access.js b/lib/project-access.js new file mode 100644 index 00000000..ff07bc31 --- /dev/null +++ b/lib/project-access.js @@ -0,0 +1,65 @@ +// The one place that answers "may this user touch that project?". +// +// Every caller (HTTP gate, WebSocket upgrade, per-message gate, project +// lists, palette, schedules) goes through here so no site can forget the +// check or treat a failed lookup as permission. A lookup that cannot be +// completed (no lookup wired, project unknown, lookup error, no user) is a +// refusal, never an allow. +// +// deps: +// users module exposing canAccessProject(userId, access) +// onGetProjectAccess function(slug) -> access object | { error } | falsy + +function createProjectAccess(deps) { + var users = deps.users; + var onGetProjectAccess = deps.onGetProjectAccess; + + // 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; + } + + function canAccess(userId, slug) { + if (!userId) return false; + var access = resolve(slug); + if (!access) return false; + return users.canAccessProject(userId, access) === true; + } + + // Entries are project status objects (anything with a .slug). + function filterProjectList(userId, list) { + if (!userId || !Array.isArray(list)) return []; + return list.filter(function (p) { + return !!p && canAccess(userId, p.slug); + }); + } + + return { + resolve: resolve, + canAccess: canAccess, + filterProjectList: filterProjectList, + }; +} + +// 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 user +// or no session is a refusal. +function canReadSession(users, user, session) { + if (!user || !session) return false; + return users.canAccessSession(user.id, session, { visibility: "public" }) === true; +} + +module.exports = { + createProjectAccess: createProjectAccess, + canReadSession: canReadSession, +}; diff --git a/lib/project-connection.js b/lib/project-connection.js index d07e760b..fb32de8a 100644 --- a/lib/project-connection.js +++ b/lib/project-connection.js @@ -18,7 +18,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 */ @@ -168,7 +168,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) }); _loop.sendConnectionState(ws); if (_mcp) _mcp.sendConnectionState(ws); if (_notifications) _notifications.sendConnectionState(ws, sendTo); @@ -319,10 +319,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..ea4501db --- /dev/null +++ b/lib/project-file-scope.js @@ -0,0 +1,167 @@ +// 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"); + +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 +// 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) { + 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..71f41bba 100644 --- a/lib/project-filesystem.js +++ b/lib/project-filesystem.js @@ -1,6 +1,17 @@ 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"); +var { canReadSession } = require("./project-access"); + +// 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 +20,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) + * startFileWatch, stopFileWatch, startDirWatch (from _fileWatch; take the ws first) * usersModule, 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,9 +39,6 @@ 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; @@ -36,67 +48,61 @@ function attachFilesystem(ctx) { 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 git as the connection's user, inside the project. + function gitFor(ws, args) { + return projectGit.runGit(args, { cwd: cwd, osUsers: osUsers, osUserInfo: getOsUserInfoForWs(ws) }); + } 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 (!ws._clagenticUser || ws._clagenticUser.role !== "admin") { + 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 +111,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 +119,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 +150,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 +175,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 +196,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 +211,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 +282,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 +301,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 +317,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 +333,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 (!canReadSession(usersModule, ws._clagenticUser, session)) return; var sessionLocalId = session.localId; var sessionTitle = session.title || "Untitled"; var history = sm.readSessionHistoryFromDisk(session); @@ -459,10 +441,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(ws, ["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 +475,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(ws, ["diff", hash, hash2, "--", diffRelPath]); } else { - diff = execFileSync("git", ["show", hash, "--format=", "--", diffPath], - { cwd: cwd, encoding: "utf8", timeout: 5000 }); + diff = gitFor(ws, ["show", hash, "--format=", "--", diffRelPath]); } sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" }); } catch (e) { @@ -520,12 +503,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(ws, ["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..18de075b --- /dev/null +++ b/lib/project-git.js @@ -0,0 +1,45 @@ +// Running 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). Git runs as the +// caller's OS identity in os-users mode, so repository reads are bounded by +// the same file permissions as everything else the caller can do. + +var execFileSync = require("child_process").execFileSync; + +var COMMIT_HASH_RE = /^[0-9a-fA-F]{4,64}$/; + +function isCommitHash(value) { + return typeof value === "string" && COMMIT_HASH_RE.test(value); +} + +// args: git argv after the binary name +// o: { cwd, osUsers, osUserInfo, timeoutMs } +function runGit(args, o) { + if (o.osUsers && !o.osUserInfo) throw new Error("Git access requires an OS user identity"); + var execOpts = { + cwd: o.cwd, + encoding: "utf8", + timeout: o.timeoutMs || 5000, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }; + if (o.osUserInfo) { + execOpts.uid = o.osUserInfo.uid; + execOpts.gid = o.osUserInfo.gid; + // The repository is usually owned by someone else; name it as safe for + // this one invocation instead of editing any git config. + execOpts.env = { + PATH: process.env.PATH, + HOME: o.osUserInfo.home, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "safe.directory", + GIT_CONFIG_VALUE_0: o.cwd, + }; + } + return execFileSync("git", args, execOpts); +} + +module.exports = { isCommitHash: isCommitHash, runGit: runGit }; diff --git a/lib/project-http.js b/lib/project-http.js index 5f608850..ee686be3 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 = { @@ -58,7 +59,7 @@ function parseJsonBody(req) { * * ctx fields: * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule, - * safePath, safeAbsPath, getOsUserInfoForReq, sendExtensionCommandAny, + * getOsUserInfoForReq, sendExtensionCommandAny, * _extToken, _browserTabList */ function attachHTTP(ctx) { @@ -70,8 +71,6 @@ function attachHTTP(ctx) { var imagesDir = ctx.imagesDir; var osUsers = ctx.osUsers; var pushModule = ctx.pushModule; - var safePath = ctx.safePath; - var safeAbsPath = ctx.safeAbsPath; var getOsUserInfoForReq = ctx.getOsUserInfoForReq; var sendExtensionCommandAny = ctx.sendExtensionCommandAny; var _extToken = ctx._extToken; @@ -280,15 +279,19 @@ 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); + var fileAccessError = req._clagenticUser + ? 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..8c9cf58c 100644 --- a/lib/project-loop.js +++ b/lib/project-loop.js @@ -35,7 +35,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 +50,11 @@ function attachLoop(ctx) { var pushModule = ctx.pushModule; var notificationsModule = ctx.notificationsModule; var getHubSchedules = ctx.getHubSchedules; + // getHubSchedules(userId) is limited to what that user may see; a context + // without a per-client broadcaster sends the list for no user (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 +377,7 @@ function attachLoop(ctx) { triggerFromQueue(record); }, onChange: function () { - send({ type: "loop_registry_updated", records: getHubSchedules() }); + broadcastHubSchedules(); }, }); loopRegistry.load(); @@ -1389,7 +1394,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) }); return true; } @@ -1445,7 +1450,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) }); 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..a793e6d8 --- /dev/null +++ b/lib/project-message-gate.js @@ -0,0 +1,59 @@ +// 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) -> boolean fail-closed project access check +// (null for a bare context with no access wiring) +// getProject(slug) -> project context | null + +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 wired = typeof canAccess === "function"; + + if (wired && (!user || !canAccess(user.id, slug))) 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 || !user || typeof getProject !== "function") return refuse(DENIED); + if (!canAccess(user.id, named)) 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..f4faac83 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -11,6 +11,9 @@ var agentsFavorites = require("./agents-favorites"); var sessionActivity = require("./session-activity"); var { promptsFor } = require("./prompt-registry"); var { attachPromptResponses } = require("./project-prompt-responses"); +var { canReadSession } = require("./project-access"); +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). @@ -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) && canReadSession(usersModule, ws._clagenticUser, sm.sessions.get(msg.id))) { 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 && canReadSession(usersModule, ws._clagenticUser, sm.sessions.get(msg.id))) { var s = sm.sessions.get(msg.id); s.title = String(msg.title).substring(0, 100); s.titleManuallySet = true; @@ -643,14 +655,20 @@ 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 canReadSession(usersModule, ws._clagenticUser, candidate); + }); + 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; + if (!canReadSession(usersModule, ws._clagenticUser, 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; @@ -838,6 +856,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 +900,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 +1511,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..f9ae7418 100644 --- a/lib/project.js +++ b/lib/project.js @@ -20,6 +20,10 @@ var { attachHTTP } = require("./project-http"); var { attachImage } = require("./project-image"); var { attachKnowledge } = require("./project-knowledge"); var { attachFilesystem } = require("./project-filesystem"); +var { safePath, safeAbsPath, safeClaudePath, createFileAccess } = require("./project-file-scope"); +var { createMessageGate } = require("./project-message-gate"); +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 +102,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. @@ -274,15 +238,24 @@ 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. + // an unanswered command resolves to null after its timeout. Each command + // remembers the socket it was sent to, and only that socket may answer it. + var extensionCommandTargets = new Map(); // requestId -> ws + function sendExtensionCommand(ws, command, args, timeout) { var opened = promptsFor(sm).open(null, "extension", { command: command, args: args }, { timeoutMs: timeout }); + extensionCommandTargets.set(opened.requestId, ws); + var forget = function () { extensionCommandTargets.delete(opened.requestId); }; + opened.answer.then(forget, forget); sendTo(ws, { type: "extension_command", command: command, @@ -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) }); + } + } + // --- Knowledge engine (delegated to project-knowledge.js) --- var _knowledge = attachKnowledge({ cwd: cwd, @@ -407,19 +389,29 @@ 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, + 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 +624,7 @@ function createProjectContext(opts) { pushModule: pushModule, notificationsModule: _notifications, getHubSchedules: getHubSchedules, + broadcastHubSchedules: broadcastHubSchedules, getAllProjectSessions: getAllProjectSessions, getSessionUnread: getSessionUnread, getStatus: getStatus, @@ -737,15 +730,28 @@ function createProjectContext(opts) { } } + // opts.canAccessProject(userId, slug) 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 +772,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 +798,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 +823,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; } @@ -939,6 +948,7 @@ function createProjectContext(opts) { moveScheduleToProject: moveScheduleToProject, moveAllSchedulesToProject: moveAllSchedulesToProject, getHubSchedules: getHubSchedules, + broadcastHubSchedules: broadcastHubSchedules, fetchVersion: fetchVersion, isNewer: isNewer, scheduleMessage: scheduleMessage, @@ -984,6 +994,9 @@ function createProjectContext(opts) { onProcessingChanged: onProcessingChanged, _loop: _loop, browserState: browserState, + mayAnswerExtensionCommand: function (requestId, ws) { + return extensionCommandTargets.get(requestId) === ws; + }, sendExtensionCommandAny: sendExtensionCommandAny, requestTabContext: requestTabContext, scheduleMessage: scheduleMessage, @@ -1004,9 +1017,7 @@ function createProjectContext(opts) { sm: sm, send: send, sendTo: sendTo, - safePath: safePath, - safeAbsPath: safeAbsPath, - safeClaudePath: safeClaudePath, + fileAccess: fileAccess, getOsUserInfoForWs: getOsUserInfoForWs, startFileWatch: startFileWatch, stopFileWatch: stopFileWatch, @@ -1016,9 +1027,7 @@ function createProjectContext(opts) { 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 +1133,6 @@ function createProjectContext(opts) { imagesDir: imagesDir, osUsers: osUsers, pushModule: pushModule, - safePath: safePath, - safeAbsPath: safeAbsPath, getOsUserInfoForReq: getOsUserInfoForReq, sendExtensionCommandAny: sendExtensionCommandAny, _extToken: _extToken, @@ -1190,8 +1197,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 +1310,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); + 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/server-palette.js b/lib/server-palette.js index 193899db..fd0896e7 100644 --- a/lib/server-palette.js +++ b/lib/server-palette.js @@ -4,7 +4,14 @@ function attachPalette(ctx) { var users = ctx.users; var projects = ctx.projects; var getMultiUserFromReq = ctx.getMultiUserFromReq; - var onGetProjectAccess = ctx.onGetProjectAccess; + var projectAccess = ctx.projectAccess; + + // The project's access record when the user may use that project, else null. + // A failed lookup is a refusal, never an allow. + function accessibleProject(userId, slug) { + var access = projectAccess.resolve(slug); + return access && users.canAccessProject(userId, access) ? access : null; + } function handleRequest(req, res, fullUrl) { if (req.method !== "GET" || fullUrl !== "/api/palette/search") return false; @@ -24,16 +31,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(paletteUser.id, 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 (!users.canAccessSession(paletteUser.id, session, pAccess)) return; var pItem = { projectSlug: pSlug, projectTitle: status.title || status.project, @@ -64,17 +66,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(paletteUser.id, 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 (!users.canAccessSession(paletteUser.id, session, pAccess)) return; smBySession.set(session, pCtx.sm); accessibleSessions.push(session); }); diff --git a/lib/server.js b/lib/server.js index b7e3fe97..1198b8f1 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"); @@ -231,6 +233,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 +287,10 @@ function createServer(opts) { var onUserDeleted = opts.onUserDeleted || null; var getRemovedProjects = opts.getRemovedProjects || function () { return []; }; + // The one answer to "may this user use that project": every check below goes + // through it, and a lookup that cannot be completed is a refusal. + var projectAccess = createProjectAccess({ users: users, onGetProjectAccess: onGetProjectAccess }); + // --- Auth module --- var auth = serverAuth.attachAuth({ users: users, @@ -342,7 +351,7 @@ function createServer(opts) { users: users, projects: projects, getMultiUserFromReq: getMultiUserFromReq, - onGetProjectAccess: onGetProjectAccess, + projectAccess: projectAccess, }); // --- Push module (global) --- @@ -645,28 +654,14 @@ 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; - } + if (lastProject && projects.has(lastProject) && reqUser && projectAccess.canAccess(reqUser.id, lastProject)) { + 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 (reqUser && projectAccess.canAccess(reqUser.id, s)) targetSlug = s; }); } if (targetSlug) { @@ -798,16 +793,15 @@ 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; - } + if (!httpUser || !projectAccess.canAccess(httpUser.id, slug)) { + res.writeHead(302, { "Location": "/" }); + res.end(); + return; } } @@ -920,33 +914,20 @@ function createServer(opts) { }); 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; - } + // 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 @@ -1039,20 +1020,13 @@ function createServer(opts) { // 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; - } - } + // Check project access. A worktree slug resolves to its parent's access + // inside onGetProjectAccess (daemon.js), so no slug is special-cased here. + if (!projectAccess.canAccess(wsUser.id, wsSlug)) { + 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; } wss.handleUpgrade(req, socket, head, function (ws) { @@ -1238,17 +1212,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); // 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,17 +1267,12 @@ function createServer(opts) { tls: !!tlsOptions, historyInMemMax: historyInMemMax, getProjectCount: function () { return projects.size; }, + // The projects `userId` may see; no user, no projects. 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; + return projectAccess.filterProjectList(userId, getProjects()); + }, + canAccessProject: function (userId, projSlug) { + return projectAccess.canAccess(userId, projSlug); }, // includeSelf: when true, the calling project's own sessions are included // in the uniform pass (so callers need not special-case self). The Hub @@ -1345,9 +1304,11 @@ function createServer(opts) { userId: userId, }); }, - getHubSchedules: function () { + // Schedules of the projects `userId` may see; no user, no schedules. + getHubSchedules: function (userId) { var allSchedules = []; projects.forEach(function (ctx, s) { + if (!projectAccess.canAccess(userId, s)) return; var status = ctx.getStatus(); var recs = ctx.getSchedules(); for (var i = 0; i < recs.length; i++) { @@ -1630,15 +1591,7 @@ function createServer(opts) { 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()); // Per-user DM data var userDmFavorites = userId ? users.getDmFavorites(userId) : []; var userDmHidden = userId ? users.getDmHidden(userId) : []; @@ -1669,7 +1622,22 @@ 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) { + var visible = projectAccess.filterProjectList(ws._clagenticUser ? ws._clagenticUser.id : null, msg.projects); + 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 +1743,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..245ce965 100644 --- a/lib/users-permissions.js +++ b/lib/users-permissions.js @@ -65,8 +65,10 @@ 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; + // 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; // Admin always has access var user = findUserById(userId); if (user && user.role === "admin") return true; 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/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, From bb4b8697565b4776d798e0202945d9ba277a1080 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 20:18:36 -0400 Subject: [PATCH 02/12] fix(keys): give tables keyed by outside names and ids no prototype (lr-783ba1) Session prompt stores, the request index, allowedTools, the MCP, worker and app-server correlation tables and the client tables keyed by server ids (file tree, cursors, notes, team panel, message handlers) are prototype-less maps, so a name such as constructor or __proto__ finds no entry. Co-Authored-By: Claude Sonnet 5.5 --- lib/mcp-local.js | 14 ++++++------ lib/prompt-registry.js | 16 +++++++------- lib/public/modules/app-cursors.js | 12 ++++++----- lib/public/modules/app-messages.js | 4 ++-- lib/public/modules/filebrowser.js | 34 ++++++++++++++++-------------- lib/public/modules/sticky-notes.js | 5 +++-- lib/public/modules/team-panel.js | 9 ++++---- lib/utils.js | 6 ++++-- lib/yoke/adapters/claude-worker.js | 18 +++++++++------- lib/yoke/adapters/claude.js | 8 ++++--- lib/yoke/codex-app-server.js | 5 +++-- 11 files changed, 73 insertions(+), 58 deletions(-) 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/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..78c783a4 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 @@ -114,7 +114,7 @@ var handlers = handlers || {}; * @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/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/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). From b0be912d952c9b7f79809f3252bcb56e2ed72d0a Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 20:20:14 -0400 Subject: [PATCH 03/12] test(access): cover the access gate, file policy, watches, origin check and hostile keys (lr-783ba1) Real server, real login cookies and real WebSocket clients for the gate, fail-closed lookups, lists, schedules, session actions and origin; real git and files for the file policy; real fs.watch for per-connection watches. One test per gap and per caller shape. Co-Authored-By: Claude Sonnet 5.5 --- lib/public/modules/app-messages.js | 4 +- test/access-gate-integration.test.js | 349 +++++++++++++++++ test/access-harness.js | 213 ++++++++++ test/client-supplied-keys.test.js | 136 +++++++ test/hostile-keys-stores.test.js | 190 +++++++++ test/project-access-units.test.js | 199 ++++++++++ .../project-file-watch-per-connection.test.js | 190 +++++++++ test/project-filesystem-policy.test.js | 365 ++++++++++++++++++ test/prototype-free-tables.test.js | 40 ++ test/ws-origin.test.js | 82 ++++ 10 files changed, 1766 insertions(+), 2 deletions(-) create mode 100644 test/access-gate-integration.test.js create mode 100644 test/access-harness.js create mode 100644 test/client-supplied-keys.test.js create mode 100644 test/hostile-keys-stores.test.js create mode 100644 test/project-access-units.test.js create mode 100644 test/project-file-watch-per-connection.test.js create mode 100644 test/project-filesystem-policy.test.js create mode 100644 test/prototype-free-tables.test.js create mode 100644 test/ws-origin.test.js diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index 78c783a4..154f6f8c 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -101,14 +101,14 @@ var handlers = handlers || Object.create(null); * 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 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..39360fa8 --- /dev/null +++ b/test/access-harness.js @@ -0,0 +1,213 @@ +// 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.serverOpts extra createServer options + */ +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: true, 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; + } + + 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(); + }); + } + + async function stop() { + sockets.forEach(function (ws) { try { ws.terminate(); } catch (e) {} }); + try { await relay.destroyAll(); } catch (e) {} + await new Promise(function (resolve) { relay.server.close(function () { resolve(); }); }); + if (typeof relay.server.closeAllConnections === "function") relay.server.closeAllConnections(); + fs.rmSync(home, { recursive: true, force: true }); + } + + 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/client-supplied-keys.test.js b/test/client-supplied-keys.test.js new file mode 100644 index 00000000..36347473 --- /dev/null +++ b/test/client-supplied-keys.test.js @@ -0,0 +1,136 @@ +// 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); +}); + +test("a browser tab list keyed by hostile ids does not reach the prototype", async function () { + var ext = await H.connect("pub", "alice"); + ext.send({ 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" }, + ] }); + await harness.settle(150); + var handler = projectCtx("pub").getMcpBridgeHandler(); + var tools = await handler.listTools(); + var listTool = tools.filter(function (t) { return t.name === "browser_list_tabs"; })[0]; + assert.ok(listTool, "the browser tools are offered once an extension has connected"); + var result = await handler.callTool(listTool.server, "browser_list_tabs", {}); + var tabs = JSON.parse(result.content[0].text); + assert.deepEqual(tabs.map(function (t) { return t.id; }), [7], "only a tab with a numeric id is listed"); + assertPrototypesClean(); +}); + +test("only the socket an extension command was sent to can answer it", async function () { + var ext = await H.connect("pub", "alice"); + ext.send({ type: "browser_tab_list", tabs: [{ id: 1, url: "https://a.example", title: "a" }] }); + await harness.settle(150); + var forger = await H.connect("pub", "bob"); + + var handler = projectCtx("pub").getMcpBridgeHandler(); + var settled = false; + var call = handler.callTool("clay-browser", "browser_close", { tabId: 1 }).then(function (r) { settled = true; return r; }); + var command = await ext.waitFor(function (m) { return m.type === "extension_command" && m.command === "tab_close"; }); + + forger.send({ type: "extension_result", requestId: command.requestId, result: { forged: true } }); + await harness.settle(250); + assert.equal(settled, false, "another client's extension_result does not settle the command"); + + ext.send({ type: "extension_result", requestId: command.requestId, result: { ok: true } }); + var result = await call; + assert.match(result.content[0].text, /Closed tab 1/); +}); + +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/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/project-access-units.test.js b/test/project-access-units.test.js new file mode 100644 index 00000000..bb596c55 --- /dev/null +++ b/test/project-access-units.test.js @@ -0,0 +1,199 @@ +// 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, canReadSession } = require("../lib/project-access"); +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) { + return createProjectAccess({ + users: users, + onGetProjectAccess: function (slug) { + if (slug === "throws") throw new Error("lookup exploded"); + return Object.prototype.hasOwnProperty.call(table, slug) ? table[slug] : { error: "Project not found" }; + }, + }); +} + +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 () { + assert.equal(canReadSession(users, PEOPLE.owner, { ownerId: "owner", sessionVisibility: "private" }), true); + assert.equal(canReadSession(users, PEOPLE.member, { ownerId: "owner", sessionVisibility: "private" }), false); + assert.equal(canReadSession(users, PEOPLE.member, { ownerId: "owner" }), true); + assert.equal(canReadSession(users, PEOPLE.member, { ownerId: null }), false); + assert.equal(canReadSession(users, PEOPLE.admin, { ownerId: null }), true); + assert.equal(canReadSession(users, null, { ownerId: "owner" }), false); + assert.equal(canReadSession(users, PEOPLE.owner, null), false); +}); + +// --- message gate ------------------------------------------------------------- + +function gateFor(projects, extra) { + var contexts = {}; + Object.keys(projects).forEach(function (slug) { contexts[slug] = { slug: slug }; }); + var access = accessWith(projects); + 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("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-file-watch-per-connection.test.js b/test/project-file-watch-per-connection.test.js new file mode 100644 index 00000000..2057a313 --- /dev/null +++ b/test/project-file-watch-per-connection.test.js @@ -0,0 +1,190 @@ +// 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" }); + w.startFileWatch(ws, "../project/../../etc/hostname"); + w.startFileWatch(ws, 7); + w.startFileWatch(ws, "watched.txt\u0000"); + w.startDirWatch(ws, ".."); + assert.deepEqual(ws.sent, []); +}); + +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..558c12ec --- /dev/null +++ b/test/project-filesystem-policy.test.js @@ -0,0 +1,365 @@ +// 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 { 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; + }, +}); + +function fakeSocket(user, osInfo) { + return { readyState: 1, _clagenticUser: user || null, osInfo: osInfo || null, sent: [] }; +} + +// A project's filesystem handler wired as lib/project.js wires it. +function build(options) { + options = options || {}; + var calls = { env: [], sharedEnv: [], fsAsUser: [], watch: [], unwatch: 0, dirWatch: [] }; + var sessions = new Map(); + var osUsers = !!options.osUsers; + 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, + 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, + 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(); + 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); + assert.equal(send(anonymous, rig, { type: "read_global_claude_md" }).last.type, "error"); + 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); +}); + +// --- 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"); +}); + +test("git runs as the user's OS identity in os-users mode and refuses without one", function (t) { + assert.throws(function () { projectGit.runGit(["status"], { cwd: REPO, osUsers: true, osUserInfo: null }); }, /OS user identity/); + if (typeof process.getuid !== "function" || process.getuid() !== 0) { + t.skip("needs root to run git as another uid"); + return; + } + var me = { uid: 0, gid: 0, home: SANDBOX }; + assert.match(projectGit.runGit(["log", "--format=%s", "-1"], { cwd: REPO, osUsers: true, osUserInfo: me }), /second/); + assert.equal(projectGit.runGit(["config", "--get", "safe.directory"], { cwd: REPO, osUsers: true, osUserInfo: me }).trim(), REPO); + var nobody = { uid: 65534, gid: 65534, home: SANDBOX }; + assert.throws(function () { + projectGit.runGit(["log", "-1"], { cwd: REPO, osUsers: true, osUserInfo: nobody }); + }, "an identity that cannot read the repository cannot read its history"); +}); diff --git a/test/prototype-free-tables.test.js b/test/prototype-free-tables.test.js new file mode 100644 index 00000000..557f0d15 --- /dev/null +++ b/test/prototype-free-tables.test.js @@ -0,0 +1,40 @@ +// 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"], +}; + +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) { + var plain = new RegExp("\\b" + name + "\\s*=\\s*(?:[^;=\\n]*\\|\\|\\s*)?\\{\\s*\\}"); + assert.equal(plain.test(source), false, name + " is assigned a plain {}"); + 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/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); +}); From 5c2ed0c56871e0eb12b0bc8d82387040e57fbadd Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 20:20:22 -0400 Subject: [PATCH 04/12] test(keys): fail on a missing module in require-cache resets and update null-prototype comparisons (lr-783ba1) A test that clears the require cache no longer swallows a failed require.resolve; a guard test finds the pattern in any test file. Grant and prompt-store comparisons read the prototype-less maps through a plain copy. The extension-result test names the socket its commands went to. Co-Authored-By: Claude Sonnet 5.5 --- test/custom-emoji-audit-log-lr-fc71.test.js | 2 +- test/exit-listener-leak-lr-daca.test.js | 2 +- ...t-sessions-access-filter-lr-c4da07.test.js | 4 +- ...oop-resume-ended-session-lr-fd38ac.test.js | 2 +- test/lr-1bdb-backend-correctness.test.js | 2 +- ...-shed-live-child-reclaim-lr-f36626.test.js | 2 +- test/memory-shed-lr-5e70.test.js | 2 +- ...-search-unloaded-session-lr-a3e175.test.js | 2 +- test/permission-grant-persist-lr-8b2e.test.js | 34 +++++--- ...rmission-grant-skill-key-lr-f969dc.test.js | 14 ++- ...ntom-notification-badges-lr-13c047.test.js | 6 +- ...on-hydrate-session-model-lr-041af8.test.js | 2 +- ...nnection-ownership-claim-lr-768c9e.test.js | 2 +- ...onnection-restore-lastactivity-tie.test.js | 2 +- test/project-loop-message-lr-4a9c.test.js | 2 +- test/project-loop-message-lr-7025.test.js | 2 +- test/project-loop-message-lr-e31b.test.js | 2 +- test/project-loop-stop-race-lr-e823.test.js | 2 +- test/prompt-hostile-request-id.test.js | 13 ++- test/prompt-registry.test.js | 6 +- test/require-cache-reset-guard.test.js | 87 +++++++++++++++++++ ...oject-unread-per-session-lr-0aa7b6.test.js | 4 +- ...ssion-auto-title-persist-lr-62157d.test.js | 2 +- ...leted-rate-limit-cleanup-lr-0827ba.test.js | 2 +- ...sion-history-memory-leak-lr-2ea2a7.test.js | 4 +- test/session-lifecycle-lr-e0de.test.js | 2 +- ...sion-meta-only-save-loaded-lr-f940.test.js | 2 +- test/session-meta-rewrite-lr-79c6.test.js | 2 +- ...on-model-persist-restart-lr-db0437.test.js | 2 +- ...on-rewind-fork-baseindex-lr-2ea2a7.test.js | 2 +- ...session-search-streaming-lr-2ea2a7.test.js | 2 +- test/switch-session-noauth-lr-690b.test.js | 2 +- test/ws-ticket-auth-lr-de5fcb.test.js | 2 +- 33 files changed, 163 insertions(+), 57 deletions(-) create mode 100644 test/require-cache-reset-guard.test.js 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/hub-recent-sessions-access-filter-lr-c4da07.test.js b/test/hub-recent-sessions-access-filter-lr-c4da07.test.js index bff9f4cf..95fe42b1 100644 --- a/test/hub-recent-sessions-access-filter-lr-c4da07.test.js +++ b/test/hub-recent-sessions-access-filter-lr-c4da07.test.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..e14729ac 100644 --- a/test/permission-grant-persist-lr-8b2e.test.js +++ b/test/permission-grant-persist-lr-8b2e.test.js @@ -30,6 +30,12 @@ var os = require("os"); var { createSDKBridge } = require("../lib/sdk-bridge"); +// A session's grant map has no prototype (its keys are tool names), so +// deepEqual against an object literal compares a plain copy. +function plain(map) { + return Object.assign({}, map); +} + function makeTempHome() { return fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-test-lr-8b2e-")); } @@ -37,7 +43,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 +114,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 +196,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 +216,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 +232,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 +264,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 +307,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 +350,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 +360,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 +377,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..447288ef 100644 --- a/test/permission-grant-skill-key-lr-f969dc.test.js +++ b/test/permission-grant-skill-key-lr-f969dc.test.js @@ -34,6 +34,12 @@ 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), so +// deepEqual against an object literal compares a plain copy. +function plain(map) { + return Object.assign({}, map); +} + function makeTempHome() { return fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-test-lr-f969dc-")); } @@ -41,7 +47,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 +171,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 +207,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 +237,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-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-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..229739b0 100644 --- a/test/prompt-registry.test.js +++ b/test/prompt-registry.test.js @@ -118,7 +118,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, {}); + assert.deepEqual(Object.keys(s.pendingPermissions), []); assert.equal(h.index[opened.requestId], undefined); assert.deepEqual(h.dismissed, [opened.requestId, opened.requestId]); }); @@ -398,8 +398,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, {}); + assert.deepEqual(Object.keys(s.pendingPermissions), []); + assert.deepEqual(Object.keys(s.activeTaskToolIds), []); assert.ok(eventsOf(h, "prompt_cancel").every(function (m) { return m.reason === "query_ended"; })); }); diff --git a/test/require-cache-reset-guard.test.js b/test/require-cache-reset-guard.test.js new file mode 100644 index 00000000..14fbbb45 --- /dev/null +++ b/test/require-cache-reset-guard.test.js @@ -0,0 +1,87 @@ +// 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. + +"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.basename(__filename); + +// try { ...require.resolve(...)... } catch [(e)] { [comment] } with nothing in the catch body +var EMPTY_CATCH_AROUND_RESOLVE = /try\s*\{[^{}]*require\.resolve[^{}]*\}\s*catch\s*(?:\([^)]*\))?\s*\{\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*\}/g; + +function testFiles() { + return fs.readdirSync(TEST_DIR).filter(function (f) { + return /\.js$/.test(f) && f !== SELF; + }); +} + +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}', + ]; + bad.forEach(function (source) { + EMPTY_CATCH_AROUND_RESOLVE.lastIndex = 0; + assert.ok(EMPTY_CATCH_AROUND_RESOLVE.test(source), source); + }); + var fine = [ + 'delete require.cache[require.resolve(m)];', + 'try { delete require.cache[require.resolve(m)]; } catch (e) { throw new Error("stale " + m); }', + 'try { fs.rmSync(dir, { recursive: true }); } catch (_) {}', + ]; + fine.forEach(function (source) { + EMPTY_CATCH_AROUND_RESOLVE.lastIndex = 0; + assert.equal(EMPTY_CATCH_AROUND_RESOLVE.test(source), false, source); + }); +}); + +test("no test swallows a failed require.resolve in a require-cache reset", function () { + var offenders = []; + testFiles().forEach(function (file) { + var source = fs.readFileSync(path.join(TEST_DIR, file), "utf8"); + EMPTY_CATCH_AROUND_RESOLVE.lastIndex = 0; + var match; + while ((match = EMPTY_CATCH_AROUND_RESOLVE.exec(source)) !== null) { + offenders.push(file + ": " + match[0].replace(/\s+/g, " ").slice(0, 100)); + } + }); + 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().forEach(function (file) { + var source = fs.readFileSync(path.join(TEST_DIR, file), "utf8"); + var literal = /require\.resolve\(\s*"(\.\.?\/[^"]+)"\s*\)/g; + var match; + while ((match = literal.exec(source)) !== null) { + try { + require.resolve(path.resolve(TEST_DIR, match[1])); + } catch (e) { + unresolved.push(file + ": " + match[1]); + } + } + var lists = /\[\s*((?:"\.\.\/[^"]+"\s*,?\s*)+)\]\s*\.forEach/g; + while ((match = lists.exec(source)) !== null) { + match[1].split(",").forEach(function (item) { + var name = item.trim().replace(/^"|"$/g, ""); + if (!name) return; + try { + require.resolve(path.resolve(TEST_DIR, name)); + } catch (e) { + unresolved.push(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/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-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; From eb79eceb3ddcfdefe61b4106aab2e7842f245a5d Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 20:30:55 -0400 Subject: [PATCH 05/12] docs(access): describe the access gate, file policy, origin rule and key handling (lr-783ba1) Also checks the owner and allow-list before reading the user store in the project access predicate, since it now runs for every message. Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/MODULE_MAP.md | 7 ++++++- docs/guides/architecture.md | 21 +++++++++++++++++++++ lib/project.js | 2 +- lib/users-permissions.js | 9 +++++---- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index 381e1d9e..b7ffa6ca 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -45,7 +45,12 @@ 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 one answer to "may this user use that project?" (fails closed), 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` | Git for a connected client: hex-only revisions, run as the caller's OS identity | +| `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..ba083fe3 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -184,6 +184,27 @@ 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 answer to "may this user use that project?" (`lib/project-access.js`) backs every check, and an answer that cannot be given is a refusal: no lookup wired, an unknown slug, a lookup that throws, or no user 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`). + +| Where | What it does | +|---|---| +| WebSocket upgrade, HTTP project routes, root redirect | `projectAccess.canAccess(user, slug)`; the local MCP bridge POST is the one request that carries no login | +| `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 | +| Palette, 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; git runs as the caller's OS identity in os-users mode. + +**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/project.js b/lib/project.js index f9ae7418..e227d14d 100644 --- a/lib/project.js +++ b/lib/project.js @@ -20,7 +20,7 @@ var { attachHTTP } = require("./project-http"); var { attachImage } = require("./project-image"); var { attachKnowledge } = require("./project-knowledge"); var { attachFilesystem } = require("./project-filesystem"); -var { safePath, safeAbsPath, safeClaudePath, createFileAccess } = require("./project-file-scope"); +var { safePath, safeClaudePath, createFileAccess } = require("./project-file-scope"); var { createMessageGate } = require("./project-message-gate"); var { ownValue, putOwn } = require("./prompt-kinds/codecs"); var { isKnownVendor } = require("./yoke"); diff --git a/lib/users-permissions.js b/lib/users-permissions.js index 245ce965..8a9ff2dc 100644 --- a/lib/users-permissions.js +++ b/lib/users-permissions.js @@ -69,14 +69,15 @@ function attachPermissions(deps) { // 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; - // Admin always has access - var user = findUserById(userId); - if (user && user.role === "admin") 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) { From 1059eb817b5b8565a6f3cdf6bdc8c83e1006fd14 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 20:38:38 -0400 Subject: [PATCH 06/12] fix(access): bind extension answers in one module and test it without browser tools (lr-783ba1) The extension-command bookkeeping moves to lib/extension-commands.js so the socket binding is tested directly; the browser tools it was first tested through exist only where an agent CLI is installed, which CI does not have. The harness retries its cleanup while an adapter is still writing under HOME. Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/MODULE_MAP.md | 1 + lib/extension-commands.js | 45 ++++++++++++++++++ lib/project.js | 29 ++++-------- test/access-harness.js | 3 +- test/client-supplied-keys.test.js | 77 +++++++++++++++++++++---------- 5 files changed, 109 insertions(+), 46 deletions(-) create mode 100644 lib/extension-commands.js diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index b7ffa6ca..d65b8067 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -50,6 +50,7 @@ Wires all modules, sets up session manager and SDK bridge, dispatches messages. | `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` | Git for a connected client: hex-only revisions, run as the caller's OS identity | +| `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 | 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/project.js b/lib/project.js index e227d14d..295ccefb 100644 --- a/lib/project.js +++ b/lib/project.js @@ -22,6 +22,7 @@ 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 { createExtensionCommands } = require("./extension-commands"); var { ownValue, putOwn } = require("./prompt-kinds/codecs"); var { isKnownVendor } = require("./yoke"); var { attachSessions } = require("./project-sessions"); @@ -247,23 +248,13 @@ function createProjectContext(opts) { }; // The extension answers with extension_result (project-user-message.js); - // an unanswered command resolves to null after its timeout. Each command - // remembers the socket it was sent to, and only that socket may answer it. - var extensionCommandTargets = new Map(); // requestId -> ws - - function sendExtensionCommand(ws, command, args, timeout) { - var opened = promptsFor(sm).open(null, "extension", { command: command, args: args }, { timeoutMs: timeout }); - extensionCommandTargets.set(opened.requestId, ws); - var forget = function () { extensionCommandTargets.delete(opened.requestId); }; - opened.answer.then(forget, forget); - 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) { @@ -994,9 +985,7 @@ function createProjectContext(opts) { onProcessingChanged: onProcessingChanged, _loop: _loop, browserState: browserState, - mayAnswerExtensionCommand: function (requestId, ws) { - return extensionCommandTargets.get(requestId) === ws; - }, + mayAnswerExtensionCommand: extensionCommands.mayAnswer, sendExtensionCommandAny: sendExtensionCommandAny, requestTabContext: requestTabContext, scheduleMessage: scheduleMessage, diff --git a/test/access-harness.js b/test/access-harness.js index 39360fa8..ef94edcd 100644 --- a/test/access-harness.js +++ b/test/access-harness.js @@ -188,7 +188,8 @@ async function start(spec) { try { await relay.destroyAll(); } catch (e) {} await new Promise(function (resolve) { relay.server.close(function () { resolve(); }); }); if (typeof relay.server.closeAllConnections === "function") relay.server.closeAllConnections(); - fs.rmSync(home, { recursive: true, force: true }); + // An adapter may still be writing under HOME as the server shuts down. + fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } return { diff --git a/test/client-supplied-keys.test.js b/test/client-supplied-keys.test.js index 36347473..6b7dd0b0 100644 --- a/test/client-supplied-keys.test.js +++ b/test/client-supplied-keys.test.js @@ -86,43 +86,70 @@ test("a known vendor is still accepted", async function () { assert.ok(vendors.indexOf("codex") !== -1); }); -test("a browser tab list keyed by hostile ids does not reach the prototype", async function () { - var ext = await H.connect("pub", "alice"); - ext.send({ type: "browser_tab_list", tabs: [ +// 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" }, + null, 5, "tab", { url: "no id" }, { id: NaN }, ] }); - await harness.settle(150); - var handler = projectCtx("pub").getMcpBridgeHandler(); - var tools = await handler.listTools(); - var listTool = tools.filter(function (t) { return t.name === "browser_list_tabs"; })[0]; - assert.ok(listTool, "the browser tools are offered once an extension has connected"); - var result = await handler.callTool(listTool.server, "browser_list_tabs", {}); - var tabs = JSON.parse(result.content[0].text); - assert.deepEqual(tabs.map(function (t) { return t.id; }), [7], "only a tab with a numeric id is listed"); + 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 ext = await H.connect("pub", "alice"); - ext.send({ type: "browser_tab_list", tabs: [{ id: 1, url: "https://a.example", title: "a" }] }); - await harness.settle(150); - var forger = await H.connect("pub", "bob"); + 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 handler = projectCtx("pub").getMcpBridgeHandler(); var settled = false; - var call = handler.callTool("clay-browser", "browser_close", { tabId: 1 }).then(function (r) { settled = true; return r; }); - var command = await ext.waitFor(function (m) { return m.type === "extension_command" && m.command === "tab_close"; }); - - forger.send({ type: "extension_result", requestId: command.requestId, result: { forged: true } }); - await harness.settle(250); + 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"); - ext.send({ type: "extension_result", requestId: command.requestId, result: { ok: true } }); - var result = await call; - assert.match(result.content[0].text, /Closed tab 1/); + 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 () { From d41374f7eb663fb0eb2fbbf2bc6627ce666ef37a Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 21:17:15 -0400 Subject: [PATCH 07/12] fix(access): decide no-user by mode through one principal resolver and run read-only git as the repository owner (lr-783ba1) A connection with no user record is the implicit owner in single-user mode and a stranger in multi-user mode; every gate, filter, route and the WebSocket upgrade asks lib/project-access.js instead of dereferencing the user, and an upgrade always answers or destroys the socket. Read-only git no longer overrides safe.directory. In os-users mode it runs as the uid that owns the repository, with fsmonitor, hooks, external diff and textconv switched off on every call. Co-Authored-By: Claude Sonnet 5.5 --- lib/project-access.js | 85 +++++++--- lib/project-file-scope.js | 4 + lib/project-filesystem.js | 23 +-- lib/project-git.js | 92 +++++++--- lib/project-http.js | 6 +- lib/project-message-gate.js | 14 +- lib/project-sessions.js | 18 +- lib/project.js | 13 ++ lib/server-hub-sessions.js | 23 +-- lib/server-palette.js | 29 ++-- lib/server.js | 64 +++++-- lib/users.js | 9 + test/access-harness.js | 37 +++- test/project-access-units.test.js | 109 ++++++++++-- test/project-filesystem-policy.test.js | 226 +++++++++++++++++++++++-- test/single-user-null-user.test.js | 218 ++++++++++++++++++++++++ 16 files changed, 840 insertions(+), 130 deletions(-) create mode 100644 test/single-user-null-user.test.js diff --git a/lib/project-access.js b/lib/project-access.js index ff07bc31..8dc49b08 100644 --- a/lib/project-access.js +++ b/lib/project-access.js @@ -1,18 +1,57 @@ -// The one place that answers "may this user touch that project?". +// 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) goes through here so no site can forget the -// check or treat a failed lookup as permission. A lookup that cannot be -// completed (no lookup wired, project unknown, lookup error, no user) is a -// refusal, never an allow. +// 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. +// +// 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: a connection that got past the login gate 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) +// 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 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. + function principalFor(user) { + if (user && typeof user === "object" && user.id) { + return { id: user.id, user: user, admin: user.role === "admin", implicit: false }; + } + if (user) return null; + if (multiUserMode()) return null; + return { id: null, user: null, admin: true, implicit: true }; + } + + function isAdmin(user) { + var principal = principalFor(user); + return !!principal && principal.admin; + } // The access record for a slug, or null when it cannot be established. function resolve(slug) { @@ -28,38 +67,46 @@ function createProjectAccess(deps) { return access; } + // userId is the id of an authenticated user, or null/undefined for none. function canAccess(userId, slug) { - if (!userId) return false; + var principal = principalFor(userId ? { id: userId } : null); + if (!principal) return false; + if (principal.implicit) return typeof slug === "string" && slug !== ""; var access = resolve(slug); if (!access) return false; - return users.canAccessProject(userId, access) === true; + return users.canAccessProject(principal.id, access) === true; } // Entries are project status objects (anything with a .slug). function filterProjectList(userId, list) { - if (!userId || !Array.isArray(list)) return []; + if (!Array.isArray(list)) return []; + if (!principalFor(userId ? { id: userId } : null)) return []; return list.filter(function (p) { return !!p && canAccess(userId, p.slug); }); } + // 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) { + var principal = principalFor(user); + if (!principal || !session) return false; + if (principal.implicit) return true; + return users.canAccessSession(principal.id, session, { visibility: "public" }) === true; + } + return { + principalFor: principalFor, + isAdmin: isAdmin, resolve: resolve, canAccess: canAccess, filterProjectList: filterProjectList, + canReadSession: canReadSession, }; } -// 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 user -// or no session is a refusal. -function canReadSession(users, user, session) { - if (!user || !session) return false; - return users.canAccessSession(user.id, session, { visibility: "public" }) === true; -} - module.exports = { createProjectAccess: createProjectAccess, - canReadSession: canReadSession, }; diff --git a/lib/project-file-scope.js b/lib/project-file-scope.js index ea4501db..333552ff 100644 --- a/lib/project-file-scope.js +++ b/lib/project-file-scope.js @@ -127,6 +127,9 @@ function listDirForUser(dir, o) { // 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) { @@ -141,6 +144,7 @@ function createFileAccess(deps) { return { // Error string when this connection may not use file features, else null. authorize: function (ws) { + if (deps.projectAccess && !deps.projectAccess.principalFor(ws._clagenticUser)) return "Authentication required"; return checkFileAccess({ user: ws._clagenticUser, osUsers: deps.osUsers, diff --git a/lib/project-filesystem.js b/lib/project-filesystem.js index 71f41bba..acb543e2 100644 --- a/lib/project-filesystem.js +++ b/lib/project-filesystem.js @@ -2,7 +2,6 @@ var fs = require("fs"); var path = require("path"); var fileScope = require("./project-file-scope"); var projectGit = require("./project-git"); -var { canReadSession } = require("./project-access"); // Settings that act on the whole machine (not on this project), so the // per-user projectSettings permission is not enough: administrators only. @@ -22,7 +21,7 @@ var ADMIN_ONLY_TYPES = { * send, sendTo * getOsUserInfoForWs (function) * startFileWatch, stopFileWatch, startDirWatch (from _fileWatch; take the ws first) - * usersModule, fsAsUser + * usersModule, projectAccess (lib/project-access.js resolver), fsAsUser * validateEnvString (function) * opts (for onGetProjectEnv, onSetProjectEnv, onGetSharedEnv, onSetSharedEnv callbacks) * fileAccess from project-file-scope.createFileAccess @@ -44,6 +43,7 @@ function attachFilesystem(ctx) { 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; @@ -57,15 +57,16 @@ function attachFilesystem(ctx) { return typeof type === "string" && type.indexOf("fs_") === 0 && type !== "fs_unwatch"; } - // Run git as the connection's user, inside the project. - function gitFor(ws, args) { - return projectGit.runGit(args, { cwd: cwd, osUsers: osUsers, osUserInfo: getOsUserInfoForWs(ws) }); + // 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) { // --- Settings that act beyond this project: administrators only --- if (typeof msg.type === "string" && ADMIN_ONLY_TYPES[msg.type] === true) { - if (!ws._clagenticUser || ws._clagenticUser.role !== "admin") { + if (!projectAccess.isAdmin(ws._clagenticUser)) { sendTo(ws, { type: "error", text: "Admin access required" }); return true; } @@ -334,7 +335,7 @@ function attachFilesystem(ctx) { // session.history/_historyLoaded/LRU state. sm.sessions.forEach(function (session) { // Edits made in a session the caller cannot read stay unseen. - if (!canReadSession(usersModule, ws._clagenticUser, session)) return; + if (!projectAccess.canReadSession(ws._clagenticUser, session)) return; var sessionLocalId = session.localId; var sessionTitle = session.title || "Untitled"; var history = sm.readSessionHistoryFromDisk(session); @@ -441,7 +442,7 @@ function attachFilesystem(ctx) { // Collect git commits try { - var gitLog = gitFor(ws, ["log", "--format=%H|%at|%an|%s", "--follow", "--", histRelPath]); + 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; @@ -484,9 +485,9 @@ function attachFilesystem(ctx) { try { var diff; if (hash2) { - diff = gitFor(ws, ["diff", hash, hash2, "--", diffRelPath]); + diff = gitFor(["diff", hash, hash2, "--", diffRelPath]); } else { - diff = gitFor(ws, ["show", hash, "--format=", "--", diffRelPath]); + diff = gitFor(["show", hash, "--format=", "--", diffRelPath]); } sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" }); } catch (e) { @@ -510,7 +511,7 @@ function attachFilesystem(ctx) { return true; } try { - var content = gitFor(ws, ["show", atHash + ":" + atRelPath]); + 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 index 18de075b..b8b42bd0 100644 --- a/lib/project-git.js +++ b/lib/project-git.js @@ -1,45 +1,95 @@ -// Running git on behalf of a connected client. +// 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). Git runs as the -// caller's OS identity in os-users mode, so repository reads are bounded by -// the same file permissions as everything else the caller can do. +// 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 its .git), 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 execFileSync = require("child_process").execFileSync; +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 uid/gid that owns the repository at `cwd`. Throws when it cannot be +// established: an unreadable repository is refused, never run as someone else. +function repositoryOwner(cwd) { + var st; + try { + st = fs.statSync(path.join(cwd, ".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, osUserInfo, timeoutMs } +// o: { cwd, osUsers, timeoutMs, exec } +// exec replaces child_process.execFileSync (tests observe the identity used). function runGit(args, o) { - if (o.osUsers && !o.osUserInfo) throw new Error("Git access requires an OS user identity"); var execOpts = { cwd: o.cwd, encoding: "utf8", timeout: o.timeoutMs || 5000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], - }; - if (o.osUserInfo) { - execOpts.uid = o.osUserInfo.uid; - execOpts.gid = o.osUserInfo.gid; - // The repository is usually owned by someone else; name it as safe for - // this one invocation instead of editing any git config. - execOpts.env = { + env: { PATH: process.env.PATH, - HOME: o.osUserInfo.home, + HOME: process.env.HOME, GIT_TERMINAL_PROMPT: "0", - GIT_CONFIG_COUNT: "1", - GIT_CONFIG_KEY_0: "safe.directory", - GIT_CONFIG_VALUE_0: o.cwd, - }; + 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; } - return execFileSync("git", args, execOpts); + var exec = o.exec || childProcess.execFileSync; + return exec("git", buildArgs(args), execOpts); } -module.exports = { isCommitHash: isCommitHash, runGit: runGit }; +module.exports = { + isCommitHash: isCommitHash, + repositoryOwner: repositoryOwner, + buildArgs: buildArgs, + runGit: runGit, +}; diff --git a/lib/project-http.js b/lib/project-http.js index ee686be3..50d48e6d 100644 --- a/lib/project-http.js +++ b/lib/project-http.js @@ -58,7 +58,7 @@ function parseJsonBody(req) { * Attach HTTP request handler to a project context. * * ctx fields: - * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule, + * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule, projectAccess, * getOsUserInfoForReq, sendExtensionCommandAny, * _extToken, _browserTabList */ @@ -71,6 +71,7 @@ function attachHTTP(ctx) { var imagesDir = ctx.imagesDir; var osUsers = ctx.osUsers; var pushModule = ctx.pushModule; + var projectAccess = ctx.projectAccess; var getOsUserInfoForReq = ctx.getOsUserInfoForReq; var sendExtensionCommandAny = ctx.sendExtensionCommandAny; var _extToken = ctx._extToken; @@ -283,7 +284,8 @@ function attachHTTP(ctx) { // 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); - var fileAccessError = req._clagenticUser + // No user is the single-user owner or a stranger by the daemon's mode. + var fileAccessError = projectAccess.principalFor(req._clagenticUser) ? fileScope.checkFileAccess({ user: req._clagenticUser, osUsers: osUsers, osUserInfo: fileServeUserInfo, usersModule: usersModule }) : "Authentication required"; if (fileAccessError) { res.writeHead(403); res.end(fileAccessError); return true; } diff --git a/lib/project-message-gate.js b/lib/project-message-gate.js index a793e6d8..0072d5b6 100644 --- a/lib/project-message-gate.js +++ b/lib/project-message-gate.js @@ -11,8 +11,11 @@ // // deps: // slug this project's slug -// canAccess(userId, slug) -> boolean fail-closed project access check -// (null for a bare context with no access wiring) +// canAccess(userId, slug) -> boolean fail-closed project access check; a +// null userId is the implicit single-user +// owner or a stranger, by the daemon's +// mode (lib/project-access.js). Null for a +// bare context with no access wiring. // getProject(slug) -> project context | null var DENIED = "Project not found or access denied"; @@ -31,9 +34,10 @@ function createMessageGate(deps) { 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"; - if (wired && (!user || !canAccess(user.id, slug))) return refuse(DENIED); + if (wired && !canAccess(userId, slug)) return refuse(DENIED); var named = msg.targetSlug; var hasTarget = named !== undefined && named !== null && named !== ""; @@ -46,8 +50,8 @@ function createMessageGate(deps) { 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 || !user || typeof getProject !== "function") return refuse(DENIED); - if (!canAccess(user.id, named)) return refuse(DENIED); + if (!wired || typeof getProject !== "function") return refuse(DENIED); + if (!canAccess(userId, named)) return refuse(DENIED); var target = getProject(named); if (!target) return refuse(DENIED); return { allowed: true, error: null, target: target, message: forwarded }; diff --git a/lib/project-sessions.js b/lib/project-sessions.js index f4faac83..a46aa896 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -11,7 +11,6 @@ var agentsFavorites = require("./agents-favorites"); var sessionActivity = require("./session-activity"); var { promptsFor } = require("./prompt-registry"); var { attachPromptResponses } = require("./project-prompt-responses"); -var { canReadSession } = require("./project-access"); var { ownValue } = require("./prompt-kinds/codecs"); var { isKnownVendor } = require("./yoke"); // Kick off SDK agent discovery in the background at module load. @@ -94,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; @@ -631,14 +631,14 @@ function attachSessions(ctx) { } } // The permission to delete is not access to this particular session. - if (msg.id && sm.sessions.has(msg.id) && canReadSession(usersModule, ws._clagenticUser, sm.sessions.get(msg.id))) { + if (msg.id && sm.sessions.has(msg.id) && projectAccess.canReadSession(ws._clagenticUser, sm.sessions.get(msg.id))) { sm.deleteSession(msg.id, ws); } return true; } if (msg.type === "rename_session") { - if (msg.id && sm.sessions.has(msg.id) && msg.title && canReadSession(usersModule, ws._clagenticUser, sm.sessions.get(msg.id))) { + if (msg.id && sm.sessions.has(msg.id) && msg.title && projectAccess.canReadSession(ws._clagenticUser, sm.sessions.get(msg.id))) { var s = sm.sessions.get(msg.id); s.title = String(msg.title).substring(0, 100); s.titleManuallySet = true; @@ -659,7 +659,7 @@ function attachSessions(ctx) { // 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 canReadSession(usersModule, ws._clagenticUser, candidate); + return projectAccess.canReadSession(ws._clagenticUser, candidate); }); sendTo(ws, { type: "search_results", query: searchQuery, results: results }); return true; @@ -668,10 +668,12 @@ function attachSessions(ctx) { if (msg.type === "search_session_content") { var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws); if (!targetSession) return true; - if (!canReadSession(usersModule, ws._clagenticUser, 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)) 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; } diff --git a/lib/project.js b/lib/project.js index 295ccefb..7f3a9cb4 100644 --- a/lib/project.js +++ b/lib/project.js @@ -22,6 +22,7 @@ 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"); @@ -148,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; @@ -384,6 +393,7 @@ function createProjectContext(opts) { var fileAccess = createFileAccess({ osUsers: osUsers, usersModule: usersModule, + projectAccess: projectAccess, fsAsUser: fsAsUser, getOsUserInfoForWs: getOsUserInfoForWs, binaryExts: BINARY_EXTS, @@ -923,6 +933,7 @@ function createProjectContext(opts) { sendToSessionOthers: sendToSessionOthers, opts: opts, usersModule: usersModule, + projectAccess: projectAccess, userPresence: userPresence, pushModule: pushModule, getSessionForWs: getSessionForWs, @@ -1012,6 +1023,7 @@ function createProjectContext(opts) { stopFileWatch: stopFileWatch, startDirWatch: startDirWatch, usersModule: usersModule, + projectAccess: projectAccess, fsAsUser: fsAsUser, validateEnvString: validateEnvString, opts: opts, @@ -1122,6 +1134,7 @@ function createProjectContext(opts) { imagesDir: imagesDir, osUsers: osUsers, pushModule: pushModule, + projectAccess: projectAccess, getOsUserInfoForReq: getOsUserInfoForReq, sendExtensionCommandAny: sendExtensionCommandAny, _extToken: _extToken, 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 fd0896e7..4e95bc57 100644 --- a/lib/server-palette.js +++ b/lib/server-palette.js @@ -6,18 +6,27 @@ function attachPalette(ctx) { var getMultiUserFromReq = ctx.getMultiUserFromReq; var projectAccess = ctx.projectAccess; - // The project's access record when the user may use that project, else null. - // A failed lookup is a refusal, never an allow. - function accessibleProject(userId, slug) { + // 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(userId, access) ? access : null; + 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) { + // No principal (no user in multi-user mode) is unauthenticated. + var principal = projectAccess.principalFor(getMultiUserFromReq(req)); + if (!principal) { res.writeHead(401, { "Content-Type": "application/json" }); res.end('{"error":"unauthorized"}'); return true; @@ -31,11 +40,11 @@ function attachPalette(ctx) { projects.forEach(function (pCtx, pSlug) { var status = pCtx.getStatus(); if (status.isWorktree) return; - var pAccess = accessibleProject(paletteUser.id, pSlug); + var pAccess = accessibleProject(principal, pSlug); if (!pAccess) return; pCtx.sm.sessions.forEach(function (session) { if (session.hidden) return; - if (!users.canAccessSession(paletteUser.id, session, pAccess)) return; + if (!canSeeSession(principal, session, pAccess)) return; var pItem = { projectSlug: pSlug, projectTitle: status.title || status.project, @@ -66,12 +75,12 @@ function attachPalette(ctx) { projects.forEach(function (pCtx, pSlug) { var status = pCtx.getStatus(); if (status.isWorktree) return; - var pAccess = accessibleProject(paletteUser.id, pSlug); + var pAccess = accessibleProject(principal, pSlug); if (!pAccess) return; var accessibleSessions = []; pCtx.sm.sessions.forEach(function (session) { if (session.hidden) return; - if (!users.canAccessSession(paletteUser.id, session, pAccess)) 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 1198b8f1..c1a289ec 100644 --- a/lib/server.js +++ b/lib/server.js @@ -131,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 @@ -287,9 +291,15 @@ function createServer(opts) { var onUserDeleted = opts.onUserDeleted || null; var getRemovedProjects = opts.getRemovedProjects || function () { return []; }; - // The one answer to "may this user use that project": every check below goes - // through it, and a lookup that cannot be completed is a refusal. - var projectAccess = createProjectAccess({ users: users, onGetProjectAccess: onGetProjectAccess }); + // 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({ @@ -654,14 +664,15 @@ 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) && reqUser && projectAccess.canAccess(reqUser.id, lastProject)) { + var reqUserId = reqUser ? reqUser.id : null; + if (lastProject && projects.has(lastProject) && projectAccess.canAccess(reqUserId, lastProject)) { targetSlug = lastProject; } // Fall back to first accessible project if (!targetSlug) { projects.forEach(function (ctx, s) { if (targetSlug) return; - if (reqUser && projectAccess.canAccess(reqUser.id, s)) targetSlug = s; + if (projectAccess.canAccess(reqUserId, s)) targetSlug = s; }); } if (targetSlug) { @@ -798,7 +809,7 @@ function createServer(opts) { // request must belong to a user who may use this project. if (!isMcpBridgeLocal) { var httpUser = getMultiUserFromReq(req); - if (!httpUser || !projectAccess.canAccess(httpUser.id, slug)) { + if (!projectAccess.canAccess(httpUser ? httpUser.id : null, slug)) { res.writeHead(302, { "Location": "/" }); res.end(); return; @@ -913,7 +924,19 @@ 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) { + 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. @@ -955,6 +978,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)) { + wsAuthedUser = IMPLICIT_OWNER; + } if (!wsAuthedUser) { // Observability (DRUMMER-flagged, lr-de5fcb): debug-gated only, never @@ -1019,11 +1049,11 @@ 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; + 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. - if (!projectAccess.canAccess(wsUser.id, wsSlug)) { - if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug); + if (!projectAccess.canAccess(wsUser ? wsUser.id : null, wsSlug)) { + 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; @@ -1085,7 +1115,7 @@ function createServer(opts) { } ctx.handleConnection(ws, wsUser); }); - }); + } // --- Cross-project unread tracking --- // WeakMap tracks how many done events @@ -1267,13 +1297,15 @@ function createServer(opts) { tls: !!tlsOptions, historyInMemMax: historyInMemMax, getProjectCount: function () { return projects.size; }, - // The projects `userId` may see; no user, no projects. + // 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) { return projectAccess.filterProjectList(userId, getProjects()); }, canAccessProject: function (userId, projSlug) { return projectAccess.canAccess(userId, projSlug); }, + 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 — @@ -1288,12 +1320,8 @@ 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. + // 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) { return computeAllProjectSessions({ projects: projects, @@ -1302,6 +1330,7 @@ function createServer(opts) { callerSlug: slug, includeSelf: includeSelf, userId: userId, + implicitOwner: !userId && !!projectAccess.principalFor(null), }); }, // Schedules of the projects `userId` may see; no user, no schedules. @@ -1627,6 +1656,7 @@ function createServer(opts) { 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); ws.send(JSON.stringify(Object.assign({}, msg, { projects: visible, projectCount: visible.length }))); }); 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/test/access-harness.js b/test/access-harness.js index ef94edcd..448c9d67 100644 --- a/test/access-harness.js +++ b/test/access-harness.js @@ -42,7 +42,9 @@ function userRecord(spec) { * `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-")); @@ -60,7 +62,7 @@ async function start(spec) { return rec; }); fs.writeFileSync(path.join(consoleDir, "users.json"), JSON.stringify({ - multiUser: true, setupCode: null, users: records, invites: [], smtp: null, + multiUser: spec.multiUser !== false, setupCode: null, users: records, invites: [], smtp: null, }), { mode: 0o600 }); var tokens = {}; @@ -95,6 +97,9 @@ async function start(spec) { 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, @@ -183,13 +188,35 @@ async function start(spec) { }); } + // 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() { - sockets.forEach(function (ws) { try { ws.terminate(); } catch (e) {} }); - try { await relay.destroyAll(); } catch (e) {} + 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. - fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + // 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 { diff --git a/test/project-access-units.test.js b/test/project-access-units.test.js index bb596c55..7e771820 100644 --- a/test/project-access-units.test.js +++ b/test/project-access-units.test.js @@ -13,7 +13,8 @@ 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, canReadSession } = require("../lib/project-access"); +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"); @@ -46,9 +47,10 @@ test("a project record with no visibility is private, not public", function () { // --- resolver --------------------------------------------------------------- -function accessWith(table) { +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" }; @@ -56,6 +58,9 @@ function accessWith(table) { }); } +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); @@ -89,21 +94,85 @@ test("a project list is filtered to what the user may see and is empty for no us }); test("a session is readable by its owner, by anyone when shared, and by an administrator when legacy", function () { - assert.equal(canReadSession(users, PEOPLE.owner, { ownerId: "owner", sessionVisibility: "private" }), true); - assert.equal(canReadSession(users, PEOPLE.member, { ownerId: "owner", sessionVisibility: "private" }), false); - assert.equal(canReadSession(users, PEOPLE.member, { ownerId: "owner" }), true); - assert.equal(canReadSession(users, PEOPLE.member, { ownerId: null }), false); - assert.equal(canReadSession(users, PEOPLE.admin, { ownerId: null }), true); - assert.equal(canReadSession(users, null, { ownerId: "owner" }), false); - assert.equal(canReadSession(users, PEOPLE.owner, null), false); + 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 principal = access.principalFor(null); + assert.deepEqual({ id: principal.id, implicit: principal.implicit, admin: principal.admin }, { id: null, implicit: true, admin: true }); + assert.equal(access.isAdmin(null), true); + assert.equal(access.isAdmin(undefined), true); + assert.equal(access.canAccess(null, "open"), true); + assert.equal(access.canAccess(null, "closed"), true); + assert.equal(access.canAccess(null, "a--worktree"), true, "a worktree slug needs no lookup for the owner"); + assert.equal(access.canAccess(null, undefined), false, "a slug that is not a name is still refused"); + assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }, { slug: "closed" }, null]).map(function (p) { return p.slug; }), ["open", "closed"]); + assert.equal(access.canReadSession(null, { ownerId: "someone", sessionVisibility: "private" }), true); + assert.equal(access.canReadSession(null, null), false); +}); + +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) { + assert.equal(access.principalFor(null), null); + assert.equal(access.principalFor(undefined), null); + assert.equal(access.principalFor({}), null, "a record with no id is no principal"); + assert.equal(access.isAdmin(null), false); + assert.equal(access.canAccess(null, "open"), false); + assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }]), []); + assert.equal(access.canReadSession(null, { ownerId: "owner" }), 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), 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); + var access = accessWith(projects, isMultiUser); return createMessageGate(Object.assign({ slug: "here", canAccess: access.canAccess, @@ -170,6 +239,26 @@ test("a message that is not a JSON object is refused without an error to send", }); }); +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 own = gate.authorize({ _clagenticUser: null }, { type: "x" }); + assert.equal(own.allowed, true); + var other = gate.authorize({ _clagenticUser: null }, { 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({ _clagenticUser: null }, { type: "x", targetSlug: "nowhere" }).allowed, false, "a project that does not exist is still refused"); + assert.equal(gate.authorize({ _clagenticUser: null }, { type: "x", targetSlug: 5 }).allowed, false); +}); + +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); diff --git a/test/project-filesystem-policy.test.js b/test/project-filesystem-policy.test.js index 558c12ec..9033e76f 100644 --- a/test/project-filesystem-policy.test.js +++ b/test/project-filesystem-policy.test.js @@ -24,6 +24,7 @@ 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"); @@ -73,11 +74,19 @@ function fakeSocket(user, osInfo) { } // 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 }); @@ -87,7 +96,7 @@ function build(options) { return { ok: true }; } var fileAccess = createFileAccess({ - osUsers: osUsers, usersModule: usersModule, fsAsUser: fsAsUser, + osUsers: osUsers, usersModule: usersModule, fsAsUser: fsAsUser, projectAccess: projectAccess, getOsUserInfoForWs: getOsUserInfoForWs, binaryExts: new Set([".png"]), maxSize: 512 * 1024, }); @@ -102,6 +111,7 @@ function build(options) { 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 }; }, @@ -151,7 +161,7 @@ test("project env still needs the projectSettings permission", function () { 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(); + 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; @@ -159,7 +169,10 @@ test("the global CLAUDE.md and the shared environment are for administrators onl assert.match(res.text, /admin/i); }); var anonymous = fakeSocket(null); - assert.equal(send(anonymous, rig, { type: "read_global_claude_md" }).last.type, "error"); + ["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, []); @@ -171,6 +184,56 @@ test("the global CLAUDE.md and the shared environment are for administrators onl 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"); +}); + // --- The file gate covers every fs_* message but fs_unwatch ------------------ test("every fs_ message except fs_unwatch needs the fileBrowser permission", function () { @@ -349,17 +412,156 @@ test("in os-users mode files are read, listed and written as the user's own iden assert.equal(fs.readFileSync(path.join(REPO, "src", "a.txt"), "utf8"), "one\ntwo\n", "the daemon wrote nothing itself"); }); -test("git runs as the user's OS identity in os-users mode and refuses without one", function (t) { - assert.throws(function () { projectGit.runGit(["status"], { cwd: REPO, osUsers: true, osUserInfo: null }); }, /OS user identity/); +// --- 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("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 me = { uid: 0, gid: 0, home: SANDBOX }; - assert.match(projectGit.runGit(["log", "--format=%s", "-1"], { cwd: REPO, osUsers: true, osUserInfo: me }), /second/); - assert.equal(projectGit.runGit(["config", "--get", "safe.directory"], { cwd: REPO, osUsers: true, osUserInfo: me }).trim(), REPO); - var nobody = { uid: 65534, gid: 65534, home: SANDBOX }; - assert.throws(function () { - projectGit.runGit(["log", "-1"], { cwd: REPO, osUsers: true, osUserInfo: nobody }); - }, "an identity that cannot read the repository cannot read its history"); + 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"); }); 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; + } +}); From 1e47b32e818dee4c27f4a7e836d87f0d595f8de6 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 21:17:18 -0400 Subject: [PATCH 08/12] test(access): tighten the guards, grants, watch and registry assertions flagged in review (lr-783ba1) Co-Authored-By: Claude Sonnet 5.5 --- test/permission-grant-persist-lr-8b2e.test.js | 12 +- ...rmission-grant-skill-key-lr-f969dc.test.js | 12 +- .../project-file-watch-per-connection.test.js | 21 ++- test/prompt-registry.test.js | 12 +- test/prototype-free-tables.test.js | 32 +++- test/require-cache-reset-guard.test.js | 155 +++++++++++++----- 6 files changed, 193 insertions(+), 51 deletions(-) diff --git a/test/permission-grant-persist-lr-8b2e.test.js b/test/permission-grant-persist-lr-8b2e.test.js index e14729ac..9d3525cf 100644 --- a/test/permission-grant-persist-lr-8b2e.test.js +++ b/test/permission-grant-persist-lr-8b2e.test.js @@ -30,10 +30,16 @@ var os = require("os"); var { createSDKBridge } = require("../lib/sdk-bridge"); -// A session's grant map has no prototype (its keys are tool names), so -// deepEqual against an object literal compares a plain copy. +// 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) { - return Object.assign({}, 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() { diff --git a/test/permission-grant-skill-key-lr-f969dc.test.js b/test/permission-grant-skill-key-lr-f969dc.test.js index 447288ef..68f8aa3f 100644 --- a/test/permission-grant-skill-key-lr-f969dc.test.js +++ b/test/permission-grant-skill-key-lr-f969dc.test.js @@ -34,10 +34,16 @@ 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), so -// deepEqual against an object literal compares a plain copy. +// 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) { - return Object.assign({}, 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() { diff --git a/test/project-file-watch-per-connection.test.js b/test/project-file-watch-per-connection.test.js index 2057a313..a6dca1ad 100644 --- a/test/project-file-watch-per-connection.test.js +++ b/test/project-file-watch-per-connection.test.js @@ -133,11 +133,30 @@ test("a watch ends when its connection loses the permission, without sending the 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, ".."); - assert.deepEqual(ws.sent, []); + 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 () { diff --git a/test/prompt-registry.test.js b/test/prompt-registry.test.js index 229739b0..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(Object.keys(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(Object.keys(s.pendingPermissions), []); - assert.deepEqual(Object.keys(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 index 557f0d15..2815d19e 100644 --- a/test/prototype-free-tables.test.js +++ b/test/prototype-free-tables.test.js @@ -27,12 +27,40 @@ var TABLES = { "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) { - var plain = new RegExp("\\b" + name + "\\s*=\\s*(?:[^;=\\n]*\\|\\|\\s*)?\\{\\s*\\}"); - assert.equal(plain.test(source), false, name + " is assigned a plain {}"); + // 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 index 14fbbb45..d8fa26ca 100644 --- a/test/require-cache-reset-guard.test.js +++ b/test/require-cache-reset-guard.test.js @@ -2,6 +2,13 @@ // 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"; @@ -11,15 +18,94 @@ var fs = require("fs"); var path = require("path"); var TEST_DIR = __dirname; -var SELF = path.basename(__filename); +var SELF = path.join(TEST_DIR, path.basename(__filename)); -// try { ...require.resolve(...)... } catch [(e)] { [comment] } with nothing in the catch body -var EMPTY_CATCH_AROUND_RESOLVE = /try\s*\{[^{}]*require\.resolve[^{}]*\}\s*catch\s*(?:\([^)]*\))?\s*\{\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*\}/g; - -function testFiles() { - return fs.readdirSync(TEST_DIR).filter(function (f) { - return /\.js$/.test(f) && f !== SELF; +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; +} + +// Index of the brace that closes the one opened at `open`, skipping string +// literals and comments, or -1 when it never closes. +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 === '"' || 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; +} + +function isOnlyComments(text) { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").trim() === ""; +} + +// 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 (isOnlyComments(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 () { @@ -28,60 +114,51 @@ test("the guard recognises the pattern it forbids", function () { '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 (_) {}", ]; bad.forEach(function (source) { - EMPTY_CATCH_AROUND_RESOLVE.lastIndex = 0; - assert.ok(EMPTY_CATCH_AROUND_RESOLVE.test(source), 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 { fs.rmSync(dir, { recursive: true }); } catch (_) {}', + 'try { if (x) { fs.rmSync(dir, { recursive: true }); } } catch (_) {}', + 'try { if (x) { delete require.cache[require.resolve(m)]; } } catch (e) { if (e) { throw e; } }', ]; fine.forEach(function (source) { - EMPTY_CATCH_AROUND_RESOLVE.lastIndex = 0; - assert.equal(EMPTY_CATCH_AROUND_RESOLVE.test(source), false, 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().forEach(function (file) { - var source = fs.readFileSync(path.join(TEST_DIR, file), "utf8"); - EMPTY_CATCH_AROUND_RESOLVE.lastIndex = 0; - var match; - while ((match = EMPTY_CATCH_AROUND_RESOLVE.exec(source)) !== null) { - offenders.push(file + ": " + match[0].replace(/\s+/g, " ").slice(0, 100)); - } + 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().forEach(function (file) { - var source = fs.readFileSync(path.join(TEST_DIR, file), "utf8"); - var literal = /require\.resolve\(\s*"(\.\.?\/[^"]+)"\s*\)/g; - var match; - while ((match = literal.exec(source)) !== null) { + testFiles(TEST_DIR).forEach(function (file) { + namedModules(fs.readFileSync(file, "utf8")).forEach(function (name) { try { - require.resolve(path.resolve(TEST_DIR, match[1])); + require.resolve(path.resolve(path.dirname(file), name)); } catch (e) { - unresolved.push(file + ": " + match[1]); + unresolved.push(path.relative(TEST_DIR, file) + ": " + name); } - } - var lists = /\[\s*((?:"\.\.\/[^"]+"\s*,?\s*)+)\]\s*\.forEach/g; - while ((match = lists.exec(source)) !== null) { - match[1].split(",").forEach(function (item) { - var name = item.trim().replace(/^"|"$/g, ""); - if (!name) return; - try { - require.resolve(path.resolve(TEST_DIR, name)); - } catch (e) { - unresolved.push(file + ": " + name); - } - }); - } + }); }); assert.deepEqual(unresolved, []); }); From 9e63a94d1b5f150c1f64b5490344bdf8eb961b6a Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 21:17:21 -0400 Subject: [PATCH 09/12] docs(access): describe the mode-aware principal and the repository-owner git rule (lr-783ba1) Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/MODULE_MAP.md | 4 ++-- docs/guides/architecture.md | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index d65b8067..f90af54f 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -46,10 +46,10 @@ Wires all modules, sets up session manager and SDK bridge, dispatches messages. | `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; watches belong to one connection and read as that connection's user | -| `project-access.js` | The one answer to "may this user use that project?" (fails closed), the per-user project-list filter, and the session-read rule | +| `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` | Git for a connected client: hex-only revisions, run as the caller's OS identity | +| `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 | diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index ba083fe3..880b3492 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -186,11 +186,13 @@ User provisioning lives in `daemon.js` (`provisionLinuxUser`, `grantProjectAcces ## Access Control and Input Trust -Authorization is not opt-in per handler. One answer to "may this user use that project?" (`lib/project-access.js`) backs every check, and an answer that cannot be given is a refusal: no lookup wired, an unknown slug, a lookup that throws, or no user 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`). +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`). + +**"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 (`principalFor`, `canAccess`, `isAdmin`, `filterProjectList`, `canReadSession`) 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(user, slug)`; the local MCP bridge POST is the one request that carries no login | +| WebSocket upgrade, HTTP project routes, root redirect | `projectAccess.canAccess(userId, slug)`; 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 | | Palette, session rename, delete and search, file history | the session's own owner/visibility rule (`canReadSession`) is applied before anything is read | @@ -199,7 +201,7 @@ Global settings (the global CLAUDE.md, shared environment variables) are adminis **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; git runs as the caller's OS identity in os-users mode. +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 its `.git`), 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. From 690b66174dd4fab99fccc5e3dbbf6fcd776b9189 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 21:51:01 -0400 Subject: [PATCH 10/12] fix(access): make authentication a separate input to principal resolution so an unauthenticated palette request is refused (lr-783ba1) principalFor(user, { authenticated }) yields the implicit single-user owner for a missing user only when the caller proved the login: isRequestAuthed(req) for HTTP, the WebSocket upgrade check (recorded on the connection and read with authOf) or the local MCP bridge. GET /api/palette/search had no login check ahead of it and returned every session title to an unauthenticated caller in single-user mode. Every call site of the resolver now passes the proof it has. Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/architecture.md | 11 +- lib/project-access.js | 58 ++++++--- lib/project-connection.js | 5 +- lib/project-file-scope.js | 3 +- lib/project-filesystem.js | 4 +- lib/project-http.js | 5 +- lib/project-loop.js | 12 +- lib/project-message-gate.js | 21 ++- lib/project-sessions.js | 8 +- lib/project.js | 6 +- lib/server-palette.js | 7 +- lib/server.js | 50 +++++--- .../authentication-precedes-principal.test.js | 121 ++++++++++++++++++ ...t-sessions-access-filter-lr-c4da07.test.js | 4 +- test/project-access-units.test.js | 83 +++++++++--- test/project-filesystem-policy.test.js | 69 +++++++++- 16 files changed, 376 insertions(+), 91 deletions(-) create mode 100644 test/authentication-precedes-principal.test.js diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 880b3492..e6872721 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -188,20 +188,23 @@ User provisioning lives in `daemon.js` (`provisionLinuxUser`, `grantProjectAcces 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`). -**"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 (`principalFor`, `canAccess`, `isAdmin`, `filterProjectList`, `canReadSession`) instead of dereferencing the user, so a missing user never throws. A project context built without a resolver (unit harnesses) is single-user. +**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)`; 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 | +| 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 | -| Palette, session rename, delete and search, file history | the session's own owner/visibility rule (`canReadSession`) is applied before anything is read | +| `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 its `.git`), 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. +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. diff --git a/lib/project-access.js b/lib/project-access.js index 8dc49b08..aadad28e 100644 --- a/lib/project-access.js +++ b/lib/project-access.js @@ -5,11 +5,19 @@ // no site can forget the check, dereference a missing user, or treat a failed // lookup as permission. // -// 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: a connection that got past the login gate carries no -// user record, because the connection is the implicit owner. It is -// allowed everything, and every project. +// 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. @@ -21,6 +29,20 @@ // 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; @@ -38,18 +60,20 @@ function createProjectAccess(deps) { // 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. - function principalFor(user) { + // 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) { - var principal = principalFor(user); + function isAdmin(user, opts) { + var principal = principalFor(user, opts); return !!principal && principal.admin; } @@ -68,8 +92,8 @@ function createProjectAccess(deps) { } // userId is the id of an authenticated user, or null/undefined for none. - function canAccess(userId, slug) { - var principal = principalFor(userId ? { id: userId } : null); + 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); @@ -78,11 +102,11 @@ function createProjectAccess(deps) { } // Entries are project status objects (anything with a .slug). - function filterProjectList(userId, list) { + function filterProjectList(userId, list, opts) { if (!Array.isArray(list)) return []; - if (!principalFor(userId ? { id: userId } : null)) return []; + if (!principalFor(userId ? { id: userId } : null, opts)) return []; return list.filter(function (p) { - return !!p && canAccess(userId, p.slug); + return !!p && canAccess(userId, p.slug, opts); }); } @@ -90,8 +114,8 @@ function createProjectAccess(deps) { // 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) { - var principal = principalFor(user); + 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; @@ -99,6 +123,7 @@ function createProjectAccess(deps) { return { principalFor: principalFor, + authOf: authOf, isAdmin: isAdmin, resolve: resolve, canAccess: canAccess, @@ -109,4 +134,5 @@ function createProjectAccess(deps) { module.exports = { createProjectAccess: createProjectAccess, + authOf: authOf, }; diff --git a/lib/project-connection.js b/lib/project-connection.js index fb32de8a..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. @@ -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(_userId) }); + 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); diff --git a/lib/project-file-scope.js b/lib/project-file-scope.js index 333552ff..73377533 100644 --- a/lib/project-file-scope.js +++ b/lib/project-file-scope.js @@ -9,6 +9,7 @@ var fs = require("fs"); var path = require("path"); +var { authOf } = require("./project-access"); function isUsablePath(requested) { return typeof requested === "string" && requested.indexOf("\0") === -1; @@ -144,7 +145,7 @@ function createFileAccess(deps) { return { // Error string when this connection may not use file features, else null. authorize: function (ws) { - if (deps.projectAccess && !deps.projectAccess.principalFor(ws._clagenticUser)) return "Authentication required"; + if (deps.projectAccess && !deps.projectAccess.principalFor(ws._clagenticUser, authOf(ws))) return "Authentication required"; return checkFileAccess({ user: ws._clagenticUser, osUsers: deps.osUsers, diff --git a/lib/project-filesystem.js b/lib/project-filesystem.js index acb543e2..576918e8 100644 --- a/lib/project-filesystem.js +++ b/lib/project-filesystem.js @@ -66,7 +66,7 @@ function attachFilesystem(ctx) { function handleFilesystemMessage(ws, msg) { // --- Settings that act beyond this project: administrators only --- if (typeof msg.type === "string" && ADMIN_ONLY_TYPES[msg.type] === true) { - if (!projectAccess.isAdmin(ws._clagenticUser)) { + if (!projectAccess.isAdmin(ws._clagenticUser, projectAccess.authOf(ws))) { sendTo(ws, { type: "error", text: "Admin access required" }); return true; } @@ -335,7 +335,7 @@ function attachFilesystem(ctx) { // 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)) return; + if (!projectAccess.canReadSession(ws._clagenticUser, session, projectAccess.authOf(ws))) return; var sessionLocalId = session.localId; var sessionTitle = session.title || "Untitled"; var history = sm.readSessionHistoryFromDisk(session); diff --git a/lib/project-http.js b/lib/project-http.js index 50d48e6d..e739b9a6 100644 --- a/lib/project-http.js +++ b/lib/project-http.js @@ -284,8 +284,9 @@ function attachHTTP(ctx) { // 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 or a stranger by the daemon's mode. - var fileAccessError = projectAccess.principalFor(req._clagenticUser) + // 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; } diff --git a/lib/project-loop.js b/lib/project-loop.js index 8c9cf58c..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_-]+$/; @@ -50,8 +51,9 @@ function attachLoop(ctx) { var pushModule = ctx.pushModule; var notificationsModule = ctx.notificationsModule; var getHubSchedules = ctx.getHubSchedules; - // getHubSchedules(userId) is limited to what that user may see; a context - // without a per-client broadcaster sends the list for no user (empty). + // 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) }); }; @@ -1394,7 +1396,7 @@ function attachLoop(ctx) { // --- Hub: cross-project schedule aggregation --- if (msg.type === "hub_schedules_list") { - sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules(ws._clagenticUser ? ws._clagenticUser.id : null) }); + sendTo(ws, { type: "hub_schedules", schedules: getHubSchedules(ws._clagenticUser ? ws._clagenticUser.id : null, authOf(ws)) }); return true; } @@ -1408,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); @@ -1450,7 +1452,7 @@ function attachLoop(ctx) { // --- Loop Registry messages --- if (msg.type === "loop_registry_list") { - sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules(ws._clagenticUser ? ws._clagenticUser.id : null) }); + sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules(ws._clagenticUser ? ws._clagenticUser.id : null, authOf(ws)) }); return true; } diff --git a/lib/project-message-gate.js b/lib/project-message-gate.js index 0072d5b6..f7a6b59e 100644 --- a/lib/project-message-gate.js +++ b/lib/project-message-gate.js @@ -11,14 +11,19 @@ // // deps: // slug this project's slug -// canAccess(userId, slug) -> boolean fail-closed project access check; a -// null userId is the implicit single-user -// owner or a stranger, by the daemon's -// mode (lib/project-access.js). Null for a +// 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 DENIED = "Project not found or access denied"; +var { authOf } = require("./project-access"); + +var DENIED ="Project not found or access denied"; function createMessageGate(deps) { var slug = deps.slug; @@ -37,7 +42,9 @@ function createMessageGate(deps) { var userId = user ? user.id : null; var wired = typeof canAccess === "function"; - if (wired && !canAccess(userId, slug)) return refuse(DENIED); + var proof = authOf(ws); + + if (wired && !canAccess(userId, slug, proof)) return refuse(DENIED); var named = msg.targetSlug; var hasTarget = named !== undefined && named !== null && named !== ""; @@ -51,7 +58,7 @@ function createMessageGate(deps) { // Another project: both the existence and the right to act must hold. if (!wired || typeof getProject !== "function") return refuse(DENIED); - if (!canAccess(userId, named)) 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 }; diff --git a/lib/project-sessions.js b/lib/project-sessions.js index a46aa896..2779289e 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -631,14 +631,14 @@ function attachSessions(ctx) { } } // 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))) { + 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 && projectAccess.canReadSession(ws._clagenticUser, sm.sessions.get(msg.id))) { + 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; @@ -659,7 +659,7 @@ function attachSessions(ctx) { // 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); + return projectAccess.canReadSession(ws._clagenticUser, candidate, projectAccess.authOf(ws)); }); sendTo(ws, { type: "search_results", query: searchQuery, results: results }); return true; @@ -668,7 +668,7 @@ function attachSessions(ctx) { if (msg.type === "search_session_content") { var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws); if (!targetSession) return true; - if (!projectAccess.canReadSession(ws._clagenticUser, targetSession)) return true; + 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); diff --git a/lib/project.js b/lib/project.js index 7f3a9cb4..dcc7724d 100644 --- a/lib/project.js +++ b/lib/project.js @@ -377,7 +377,7 @@ function createProjectContext(opts) { 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) }); + sendTo(hws, { type: "loop_registry_updated", records: getHubSchedules(hws._clagenticUser ? hws._clagenticUser.id : null, projectAccess.authOf(hws)) }); } } @@ -731,7 +731,7 @@ function createProjectContext(opts) { } } - // opts.canAccessProject(userId, slug) is wired by server.js for every real + // 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({ @@ -1314,7 +1314,7 @@ function createProjectContext(opts) { title = newTitle || null; // 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); + 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 }); } } diff --git a/lib/server-palette.js b/lib/server-palette.js index 4e95bc57..b602a054 100644 --- a/lib/server-palette.js +++ b/lib/server-palette.js @@ -4,6 +4,7 @@ function attachPalette(ctx) { var users = ctx.users; var projects = ctx.projects; var getMultiUserFromReq = ctx.getMultiUserFromReq; + var isRequestAuthed = ctx.isRequestAuthed; var projectAccess = ctx.projectAccess; // What the single-user owner is shown: every project, as a public one. @@ -24,8 +25,10 @@ function attachPalette(ctx) { function handleRequest(req, res, fullUrl) { if (req.method !== "GET" || fullUrl !== "/api/palette/search") return false; - // No principal (no user in multi-user mode) is unauthenticated. - var principal = projectAccess.principalFor(getMultiUserFromReq(req)); + // 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"}'); diff --git a/lib/server.js b/lib/server.js index c1a289ec..45be6ba5 100644 --- a/lib/server.js +++ b/lib/server.js @@ -361,6 +361,7 @@ function createServer(opts) { users: users, projects: projects, getMultiUserFromReq: getMultiUserFromReq, + isRequestAuthed: isRequestAuthed, projectAccess: projectAccess, }); @@ -665,14 +666,16 @@ function createServer(opts) { // Check for last-visited project cookie var lastProject = parseCookies(req)["clagentic_last_project"] || parseCookies(req)["clay_last_project"]; var reqUserId = reqUser ? reqUser.id : null; - if (lastProject && projects.has(lastProject) && projectAccess.canAccess(reqUserId, lastProject)) { + // 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 (projectAccess.canAccess(reqUserId, s)) targetSlug = s; + if (projectAccess.canAccess(reqUserId, s, rootProof)) targetSlug = s; }); } if (targetSlug) { @@ -809,7 +812,8 @@ function createServer(opts) { // request must belong to a user who may use this project. if (!isMcpBridgeLocal) { var httpUser = getMultiUserFromReq(req); - if (!projectAccess.canAccess(httpUser ? httpUser.id : null, slug)) { + // 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; @@ -824,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; @@ -982,7 +989,7 @@ function createServer(opts) { // 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)) { + if (!wsAuthedUser && isRequestAuthed(req) && projectAccess.principalFor(null, { authenticated: true })) { wsAuthedUser = IMPLICIT_OWNER; } @@ -1052,7 +1059,9 @@ function createServer(opts) { 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. - if (!projectAccess.canAccess(wsUser ? wsUser.id : null, wsSlug)) { + // 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(); @@ -1094,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 @@ -1242,7 +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 = projectAccess.filterProjectList(ws._clagenticUser ? ws._clagenticUser.id : null, allProjectsList); + 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 @@ -1299,11 +1311,11 @@ function createServer(opts) { getProjectCount: function () { return projects.size; }, // 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) { - return projectAccess.filterProjectList(userId, getProjects()); + getProjectList: function (userId, proof) { + return projectAccess.filterProjectList(userId, getProjects(), proof); }, - canAccessProject: function (userId, projSlug) { - return projectAccess.canAccess(userId, projSlug); + canAccessProject: function (userId, projSlug, proof) { + return projectAccess.canAccess(userId, projSlug, proof); }, projectAccess: projectAccess, // includeSelf: when true, the calling project's own sessions are included @@ -1322,7 +1334,7 @@ function createServer(opts) { // list. Fails CLOSED: no userId (or no onGetProjectAccess wiring) means // 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) { + getAllProjectSessions: function (includeSelf, userId, proof) { return computeAllProjectSessions({ projects: projects, users: users, @@ -1330,14 +1342,15 @@ function createServer(opts) { callerSlug: slug, includeSelf: includeSelf, userId: userId, - implicitOwner: !userId && !!projectAccess.principalFor(null), + implicitOwner: !userId && !!projectAccess.principalFor(null, proof), }); }, - // Schedules of the projects `userId` may see; no user, no schedules. - getHubSchedules: function (userId) { + // 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)) return; + if (!projectAccess.canAccess(userId, s, proof)) return; var status = ctx.getStatus(); var recs = ctx.getSchedules(); for (var i = 0; i < recs.length; i++) { @@ -1614,13 +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 = projectAccess.filterProjectList(userId, getProjects()); + var filteredProjects = projectAccess.filterProjectList(userId, getProjects(), wsProof); // Per-user DM data var userDmFavorites = userId ? users.getDmFavorites(userId) : []; var userDmHidden = userId ? users.getDmHidden(userId) : []; @@ -1657,7 +1671,7 @@ function createServer(opts) { 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); + 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 }))); }); }); 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/hub-recent-sessions-access-filter-lr-c4da07.test.js b/test/hub-recent-sessions-access-filter-lr-c4da07.test.js index 95fe42b1..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, diff --git a/test/project-access-units.test.js b/test/project-access-units.test.js index 7e771820..5b477ace 100644 --- a/test/project-access-units.test.js +++ b/test/project-access-units.test.js @@ -109,17 +109,47 @@ test("a session is readable by its owner, by anyone when shared, and by an admin 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 principal = access.principalFor(null); + 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), true); - assert.equal(access.isAdmin(undefined), true); - assert.equal(access.canAccess(null, "open"), true); - assert.equal(access.canAccess(null, "closed"), true); - assert.equal(access.canAccess(null, "a--worktree"), true, "a worktree slug needs no lookup for the owner"); - assert.equal(access.canAccess(null, undefined), false, "a slug that is not a name is still refused"); - assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }, { slug: "closed" }, null]).map(function (p) { return p.slug; }), ["open", "closed"]); - assert.equal(access.canReadSession(null, { ownerId: "someone", sessionVisibility: "private" }), true); - assert.equal(access.canReadSession(null, null), false); + 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 () { @@ -133,13 +163,15 @@ test("in multi-user mode, with no mode wired, or when the mode cannot be read, n [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(undefined), null); - assert.equal(access.principalFor({}), null, "a record with no id is no principal"); - assert.equal(access.isAdmin(null), false); - assert.equal(access.canAccess(null, "open"), false); - assert.deepEqual(access.filterProjectList(null, [{ slug: "open" }]), []); - assert.equal(access.canReadSession(null, { ownerId: "owner" }), false); + 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); }); }); @@ -157,7 +189,7 @@ test("the daemon's mode is read from the stored users and defaults to multi-user 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), null); + assert.equal(viaResolver.principalFor(null, { authenticated: true }), null); } finally { fs.rmSync(file, { force: true }); } @@ -241,14 +273,23 @@ test("a message that is not a JSON object is refused without an error to send", 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 own = gate.authorize({ _clagenticUser: null }, { type: "x" }); + var owner = { _clagenticUser: null, _clagenticAuthenticated: true }; + var own = gate.authorize(owner, { type: "x" }); assert.equal(own.allowed, true); - var other = gate.authorize({ _clagenticUser: null }, { type: "x", targetSlug: "there", id: 4 }); + 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({ _clagenticUser: null }, { type: "x", targetSlug: "nowhere" }).allowed, false, "a project that does not exist is still refused"); - assert.equal(gate.authorize({ _clagenticUser: null }, { type: "x", targetSlug: 5 }).allowed, false); + 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 () { diff --git a/test/project-filesystem-policy.test.js b/test/project-filesystem-policy.test.js index 9033e76f..27bbfa3c 100644 --- a/test/project-filesystem-policy.test.js +++ b/test/project-filesystem-policy.test.js @@ -69,8 +69,10 @@ var usersModule = attachPermissions({ }, }); -function fakeSocket(user, osInfo) { - return { readyState: 1, _clagenticUser: user || null, osInfo: osInfo || null, sent: [] }; +// 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. @@ -234,6 +236,20 @@ test("multi-user mode, no user: every file, settings and git message is refused 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 () { @@ -450,6 +466,39 @@ test("a repository whose owner cannot be established fails closed", function () 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) { @@ -565,3 +614,19 @@ test("as another user: git runs as the repository's owner and its config program 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"); +}); From f00aa60cd71f2d22c0e3e7a6f6e8a060cbe2385d Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 21:51:06 -0400 Subject: [PATCH 11/12] fix(git): resolve the repository root before the ownership stat so subdirectory projects keep history (lr-783ba1) Co-Authored-By: Claude Sonnet 5.5 --- lib/project-git.js | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/project-git.js b/lib/project-git.js index b8b42bd0..512d585a 100644 --- a/lib/project-git.js +++ b/lib/project-git.js @@ -8,7 +8,8 @@ // 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 its .git), not as the connection's user. Who may look at 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 @@ -37,12 +38,34 @@ function isCommitHash(value) { return typeof value === "string" && COMMIT_HASH_RE.test(value); } -// The uid/gid that owns the repository at `cwd`. Throws when it cannot be -// established: an unreadable repository is refused, never run as someone else. +// 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(cwd, ".git")); + st = fs.statSync(path.join(root, ".git")); } catch (e) { throw new Error("Cannot determine the repository owner"); } @@ -89,6 +112,7 @@ function runGit(args, o) { module.exports = { isCommitHash: isCommitHash, + findRepositoryRoot: findRepositoryRoot, repositoryOwner: repositoryOwner, buildArgs: buildArgs, runGit: runGit, From bc18c87b0f27c14caa9f8df493d4f1ed28a63c07 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 21:51:08 -0400 Subject: [PATCH 12/12] test(guard): skip regex literals and treat empty-statement catch bodies as empty in the require-cache guard (lr-783ba1) Co-Authored-By: Claude Sonnet 5.5 --- test/require-cache-reset-guard.test.js | 58 ++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/test/require-cache-reset-guard.test.js b/test/require-cache-reset-guard.test.js index d8fa26ca..fbb8e151 100644 --- a/test/require-cache-reset-guard.test.js +++ b/test/require-cache-reset-guard.test.js @@ -33,8 +33,42 @@ function testFiles(dir) { 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 and comments, or -1 when it never closes. +// 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++) { @@ -48,6 +82,10 @@ function closingBrace(source, open) { 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++; @@ -62,8 +100,11 @@ function closingBrace(source, open) { return -1; } -function isOnlyComments(text) { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").trim() === ""; +// 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. @@ -82,7 +123,7 @@ function emptyCatchesAroundResolve(source) { var catchOpen = close + 1 + after[0].length - 1; var catchClose = closingBrace(source, catchOpen); if (catchClose === -1) continue; - if (isOnlyComments(source.slice(catchOpen + 1, catchClose))) { + if (isEmptyBody(source.slice(catchOpen + 1, catchClose))) { found.push(source.slice(m.index, catchClose + 1).replace(/\s+/g, " ").slice(0, 100)); } } @@ -117,6 +158,13 @@ test("the guard recognises the pattern it forbids", function () { '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); @@ -126,6 +174,8 @@ test("the guard recognises the pattern it forbids", function () { '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);