oclaw/uds-auth/lib/dsh-acl.js
oliver df97c5e042
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:11:09 +08:00

1662 lines
61 KiB
JavaScript

/**
* Bridge UDS cookies → AsyncLocalStorage and wrap DSH session/workspace/settings.
*/
import { createRequire } from 'node:module'
import { resolve as resolvePath, sep as pathSep } from 'node:path'
import { withUserContext, getUserContext, runWithUserContext, enterUserContext } from './context.js'
import { resolveLocale, t } from './i18n.js'
import { extractSessionBearer, extractSessionBearerAsync } from './session/request-auth.js'
import { isLocalAdminBoxConfigured } from './local-admin.js'
import { installGatewayPolicy } from './gateway-policy.js'
import { createDesktopBootstrap } from './desktop-bootstrap.js'
const require = createRequire(import.meta.url)
/** Max time an /api/* request waits for the gateway ACL during Host startup. */
const ACL_READY_WAIT_MS = 15_000
/** True when path is outside per-user workspace root (channel/bot/harness cwd). */
export function isOutsideUserWorkspaceRoot(candidatePath, workspaceRoot) {
if (!candidatePath) return true
if (!workspaceRoot) return true
try {
const base = resolvePath(String(workspaceRoot))
const cand = resolvePath(String(candidatePath))
return cand !== base && !cand.startsWith(base + pathSep)
} catch {
return true
}
}
/** @type {WeakMap<object, object|null|undefined>} */
const upgradeSocketIdentity = new WeakMap()
function empNoOfIdentity(identity) {
return identity?.empNo || identity?.userContext?.empNo || null
}
/** Re-resolve when prior bind left null/empty (do not treat null as final). */
function resolveUpgradeIdentity(req, socket, resolveIdentitySync) {
let identity
try {
if (req && Object.prototype.hasOwnProperty.call(req, '__udsAuthIdentity')) {
identity = req.__udsAuthIdentity
} else if (socket) {
identity = upgradeSocketIdentity.get(socket)
}
} catch {
identity = undefined
}
if (!empNoOfIdentity(identity) && typeof resolveIdentitySync === 'function') {
try {
identity = resolveIdentitySync(req)
} catch {
identity = identity ?? null
}
try { if (req) req.__udsAuthIdentity = identity } catch { /* ignore */ }
if (socket) upgradeSocketIdentity.set(socket, identity)
}
return identity ?? null
}
function loadWsModules(requireFn) {
const found = []
const seen = new Set()
const push = (mod) => {
if (!mod || !(mod.WebSocketServer || mod.Server)) return
const WSS = mod.WebSocketServer || mod.Server
if (seen.has(WSS)) return
seen.add(WSS)
found.push({ mod })
}
const attempts = []
if (typeof requireFn === 'function') attempts.push(() => requireFn('ws'))
attempts.push(() => {
const { createRequire } = require('node:module')
const { join } = require('node:path')
return createRequire(join(process.cwd(), 'package.json'))('ws')
})
// Gateway often resolves its own copy under packages/api/gateway/node_modules/ws.
attempts.push(() => {
const { createRequire } = require('node:module')
const { join } = require('node:path')
return createRequire(join(process.cwd(), 'packages/api/gateway/package.json'))('ws')
})
attempts.push(() => {
const cache = requireFn?.cache || require.cache || {}
for (const id of Object.keys(cache)) {
const norm = id.replace(/\\/g, '/')
if (norm.endsWith('/node_modules/ws/index.js') || norm.endsWith('/node_modules/ws/wrapper.mjs')) {
push(cache[id].exports)
}
if (norm.includes('/node_modules/ws/lib/websocket-server')) {
const { createRequire } = require('node:module')
const pkg = id.replace(/lib[\\/]websocket-server\.js$/i, 'package.json')
try { push(createRequire(pkg)('.')) } catch { /* continue */ }
}
}
return null
})
for (const tryLoad of attempts) {
try { push(tryLoad()) } catch { /* next */ }
}
return found
}
function loadWsModule(requireFn) {
return loadWsModules(requireFn)[0]?.mod || null
}
function bindWebSocketListenersToIdentity(ws, identity) {
if (!ws || ws.__udsAuthBound) return
ws.__udsAuthBound = true
try { ws.__udsAuthIdentity = identity || null } catch { /* ignore */ }
// Gateway RemoteStreamMuxConnection registers sync `message` listeners that
// kick off async pump()/session.follow after the listener returns.
// enterWith keeps THIS connection's upgrade-time identity — never a global last-user.
const wrap = (listener) => {
if (typeof listener !== 'function') return listener
return function udsAuthBoundListener(...args) {
const live = empNoOfIdentity(ws.__udsAuthIdentity)
? ws.__udsAuthIdentity
: (empNoOfIdentity(identity) ? identity : null)
enterUserContext(live || null)
return listener.apply(this, args)
}
}
for (const method of ['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']) {
if (typeof ws[method] !== 'function') continue
const orig = ws[method].bind(ws)
ws[method] = (event, listener) => orig(event, wrap(listener))
}
}
/**
* remote.mux streams lose HTTP ALS after upgrade. Bind identity onto ws listeners.
* Lazy-load `ws` from Host module cache / cwd — plugin folder cannot require it directly.
*/
/** A10: shared live sync resolver for WS prototype upgrade (survives reload). */
const _wsIdentityLive = { resolveIdentitySync: null }
function patchOneWebSocketServer(WebSocketServer) {
if (!WebSocketServer?.prototype?.handleUpgrade) return false
if (WebSocketServer.prototype.handleUpgrade.__udsAuthPatched) return true
const orig = WebSocketServer.prototype.handleUpgrade
function udsAuthHandleUpgrade(req, socket, head, cb) {
let identity
try {
identity = resolveUpgradeIdentity(req, socket, _wsIdentityLive.resolveIdentitySync)
} catch {
identity = null
}
const wrappedCb = typeof cb === 'function'
? (wsSocket) => {
bindWebSocketListenersToIdentity(wsSocket, identity || null)
return cb(wsSocket)
}
: cb
return orig.call(this, req, socket, head, wrappedCb)
}
udsAuthHandleUpgrade.__udsAuthPatched = true
WebSocketServer.prototype.handleUpgrade = udsAuthHandleUpgrade
return true
}
function patchWebSocketServerForUdsIdentity(resolveIdentitySync) {
if (typeof resolveIdentitySync === 'function') {
_wsIdentityLive.resolveIdentitySync = resolveIdentitySync
}
const mods = loadWsModules(typeof require === 'function' ? require : null)
if (!mods.length) return false
let any = false
for (const { mod } of mods) {
const WebSocketServer = mod.WebSocketServer || mod.Server
if (patchOneWebSocketServer(WebSocketServer)) any = true
}
return any
}
/**
* Normalize session/identity auth mode → sealed_box | fallback | uds.
* Sealed-box and password-fallback must stay independent (A08) — emergency
* password off must not kill a live sealed_box principal (workspace follow).
*/
export function classifyAuthKind(input, empNoHint) {
const empNo = String(
empNoHint
|| input?.empNo
|| input?.userContext?.empNo
|| '',
)
const mode = String(
input?.kind
|| input?.authMode
|| input?.userContext?.authMode
|| input?.userData?.authMode
|| '',
)
if (
mode === 'local_admin'
|| mode === 'sealed_box'
|| mode === 'local-admin-unlock'
|| mode.includes('local-admin')
) {
return 'sealed_box'
}
if (
mode === 'fallback'
|| mode === 'fallback_password'
|| mode === 'fallback-password'
|| mode === 'fallback-login'
|| mode.includes('fallback')
) {
return 'fallback'
}
// Legacy administrator sessions without an explicit mode were password-fallback.
// Do not invent fallback when mode is an unknown non-empty string.
if (empNo === 'administrator' && (!mode || mode === 'uds')) {
return 'fallback'
}
return mode || 'uds'
}
/**
* Build ACL identity from a verified local session record only.
* Never constructs a principal from empNo cookies or non-empty upstream tokens alone.
*/
export function identityFromSessionRecord(session, rolesStore) {
if (!session?.empNo) return null
const empNo = String(session.empNo)
if (rolesStore && typeof rolesStore.isDisabled === 'function' && rolesStore.isDisabled(empNo)) {
return null
}
// A08: distinguish password-fallback vs sealed-box — do not share one enable switch.
const kind = classifyAuthKind(session, empNo)
const isSealedBox = kind === 'sealed_box'
const isPasswordFallback = kind === 'fallback'
if (isPasswordFallback
&& rolesStore && typeof rolesStore.isFallbackEnabled === 'function'
&& !rolesStore.isFallbackEnabled()) {
return null
}
// F02/E11: sealed-box sessions require a live configured box (hot disable supported).
if (isSealedBox && !isLocalAdminBoxConfigured()) {
return null
}
// F02: password sessions stamped with older cred version are rejected after rotation.
if (isPasswordFallback && rolesStore && typeof rolesStore.getFallbackCredVersion === 'function') {
const liveVer = rolesStore.getFallbackCredVersion()
const sessVer = session.userData?.fallbackCredVersion ?? session.fallbackCredVersion
if (sessVer != null && Number(sessVer) !== Number(liveVer)) {
return null
}
}
const role = rolesStore.getRole(empNo)
const userContext = {
...(session.userData || {}),
empNo,
userId: empNo,
isAuthenticated: true,
_sessionId: session.sessionId,
authMode: kind === 'uds' ? (session.userData?.authMode || 'uds') : kind,
}
// Do not expose upstream token on ACL identity objects.
if (userContext.token) delete userContext.token
return {
empNo,
role,
permissions: rolesStore.resolvePermissions(empNo, role),
userContext,
kind,
authMode: userContext.authMode,
sessionId: session.sessionId,
}
}
function lookupSessionSync(req, deps) {
const sessionStore = deps?.sessionStore
if (!sessionStore || typeof sessionStore.getByBearerSync !== 'function') return null
const extracted = extractSessionBearer(req, {
sessionBridge: deps?.sessionBridge,
isLiveBearer: (bearer) => !!sessionStore.getByBearerSync(bearer),
})
if (!extracted?.bearer) return null
const session = sessionStore.getByBearerSync(extracted.bearer)
if (!session) return null
if (
extracted.bridgeEmpNo
&& String(extracted.bridgeEmpNo) !== String(session.empNo)
) {
return null
}
return session
}
export function resolveIdentityFromRequestSync(req, deps) {
const session = lookupSessionSync(req, deps)
if (!session) return null
return identityFromSessionRecord(session, deps.rolesStore)
}
export async function resolveIdentityFromRequest(req, deps) {
const { sessionStore, rolesStore } = deps
const extracted = await extractSessionBearerAsync(req, {
sessionBridge: deps?.sessionBridge,
isLiveBearer: async (bearer) => !!(await sessionStore?.getByBearer?.(bearer)),
})
if (!extracted?.bearer) return null
let session = null
try {
session = await sessionStore.getByBearer(extracted.bearer)
} catch {
session = null
}
if (!session) return null
if (
extracted.bridgeEmpNo
&& String(extracted.bridgeEmpNo) !== String(session.empNo)
) {
return null
}
// Never bootstrap roles from ACL identity path.
return identityFromSessionRecord(session, rolesStore)
}
/**
* Patch webServer so every route handler runs inside UDS ALS.
* @param {any} server
* @param {(req: any) => Promise<object|null>} resolveIdentity
* @param {(req: any) => object|null} resolveIdentitySync
*/
export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync, validateRequest) {
if (!server) return () => {}
// R14: keep live resolver refs so reload/re-patch swaps identity store without
// leaving the first patch's closed-over resolvers permanently attached.
const live = server.__udsAuthIdentityLive || (server.__udsAuthIdentityLive = {
resolveIdentity: null,
resolveIdentitySync: null,
})
live.resolveIdentity = resolveIdentity
live.resolveIdentitySync = resolveIdentitySync
live.validateRequest = validateRequest
if (server.__udsAuthPatched) {
return () => {
/* resolvers already live-updated */
}
}
server.__udsAuthPatched = true
/**
* Desktop main reads settings/llm/credentials/account over /api/* the moment the
* Host reports ready, which can precede our gateway ACL install. A hard 403 there
* makes the native welcome read fail → hasApiKey=false → "add an API Key" prompt
* even with keys stored. Hold (bounded) until the ACL is in place instead.
*/
const validateWhenAclReady = async (req, upgrade) => {
let checked = live.validateRequest?.(req, upgrade)
const deadline = Date.now() + ACL_READY_WAIT_MS
while (checked && !checked.ok && checked.reason === 'gateway_acl_not_ready' && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50))
checked = live.validateRequest?.(req, upgrade)
}
return checked
}
const wrap = (handler) => {
if (typeof handler !== 'function' || handler.__udsWrapped) return handler
const wrapped = async (req, res, ...rest) => {
const checked = await validateWhenAclReady(req, false)
if (checked && !checked.ok) {
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: false, error: checked.reason }))
return
}
const syncFn = live.resolveIdentitySync
const asyncFn = live.resolveIdentity
const syncIdentity = typeof syncFn === 'function' ? syncFn(req) : null
let identity = syncIdentity
try {
identity = (typeof asyncFn === 'function' ? await asyncFn(req) : null) || syncIdentity
} catch {
identity = syncIdentity
}
try {
const pathname = new URL(req.url || '/', 'http://x').pathname
if (
pathname.startsWith('/dsh-ops-cron')
&& pathname !== '/dsh-ops-cron/health'
&& !identity?.empNo
) {
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({
ok: false,
error: 'login_required',
message: t('err.login_required_cron', resolveLocale(req, identity)),
}))
return
}
} catch { /* fall through to handler */ }
return withUserContext(identity, () => handler(req, res, ...rest))
}
wrapped.__udsWrapped = true
return wrapped
}
const patchTable = (table) => {
if (!table || typeof table.entries !== 'function') return
for (const [path, route] of table.entries()) {
if (!route?.handler) continue
table.set(path, { ...route, handler: wrap(route.handler) })
}
}
patchTable(server.exact)
patchTable(server.prefixes)
if (typeof server.fallback === 'function') {
server.fallback = wrap(server.fallback)
}
const origRegister = server.register.bind(server)
server.register = (route) => origRegister({
...route,
handler: wrap(route.handler),
})
const origFallback = server.registerFallback?.bind(server)
if (origFallback) {
server.registerFallback = (handler) => origFallback(wrap(handler))
}
const bindUpgradeIdentity = async (req, socket, head, prev) => {
const checked = await validateWhenAclReady(req, true)
if (checked && !checked.ok) {
socket?.end?.('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
return
}
// A10: always use live bag — never closed-over first-patch resolvers.
const syncFn = live.resolveIdentitySync
const asyncFn = live.resolveIdentity
patchWebSocketServerForUdsIdentity(syncFn)
const syncIdentity = typeof syncFn === 'function' ? syncFn(req) : null
let identity = syncIdentity
try {
identity = (typeof asyncFn === 'function' ? await asyncFn(req) : null) || syncIdentity
} catch {
identity = syncIdentity
}
if (!empNoOfIdentity(identity) && typeof syncFn === 'function') {
try { identity = syncFn(req) || identity } catch { /* keep */ }
}
try { req.__udsAuthIdentity = identity } catch { /* ignore */ }
if (socket) upgradeSocketIdentity.set(socket, identity)
return withUserContext(identity, () => prev(req, socket, head))
}
patchWebSocketServerForUdsIdentity(live.resolveIdentitySync)
try {
const table = server.upgrades
if (table && typeof table.entries === 'function') {
for (const [path, route] of table.entries()) {
if (!route?.handler || route.handler.__udsWrapped) continue
const prev = route.handler
const wrapped = async (req, socket, head) => bindUpgradeIdentity(req, socket, head, prev)
wrapped.__udsWrapped = true
table.set(path, { ...route, handler: wrapped })
}
}
} catch { /* private field / unavailable */ }
const origRegisterUpgrade = server.registerUpgrade?.bind(server)
if (origRegisterUpgrade) {
server.registerUpgrade = (route) => origRegisterUpgrade({
...route,
handler: async (req, socket, head) => bindUpgradeIdentity(req, socket, head, route.handler),
})
}
return () => {
/* leave patched — reload recreates webServer fiber */
}
}
function throwForbidden(code) {
// Structural RemoteError so Gateway rpcFailure keeps the message instead of
// remapping a plain Error to gateway/internal. Use gateway/bad-request (declared).
const locale = resolveLocale(null, getUserContext())
const raw = String(code || 'request_failed')
const key = raw.startsWith('err.') ? raw : 'err.' + raw
const message = t(key, locale)
const err = new Error(message || 'forbidden')
err.name = 'RemoteError'
err.isDSHRemoteError = true
err.code = 'gateway/bad-request'
err.details = { udsError: raw.replace(/^err\./, '') }
throw err
}
/**
* Shared session visibility helpers (sidebar + @ mention + query reads).
* Visibility:
* - canViewAllSessions (admin-class preference, default on): see all
* - else: own owner stamp OR own user-workspace path only
* (shared project / channel roots are not exposed when view-all is off)
*/
export function createSessionAccess({
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
getWorkspaceRegistry,
resolveLiveCwd,
rolesStore,
}) {
const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null
/** Always re-read prefs from live rolesStore — never trust frozen WS identity.permissions. */
const canSeeAll = (identity) => {
if (!identity) return false
const empNo = empOf(identity)
const store = typeof rolesStore === 'function' ? rolesStore() : rolesStore
if (empNo && store && typeof store.resolvePermissions === 'function') {
// Do not pass identity.role — store.getRole(empNo) is source of truth.
return !!store.resolvePermissions(empNo).canViewAllSessions
}
if (identity.permissions?.canViewAllSessions) return true
// No store (tests / misconfig): admin-class sees all by default.
// If permissions were supplied and view-all is off, respect that.
if (identity.permissions && Object.prototype.hasOwnProperty.call(identity.permissions, 'canViewAllSessions')) {
return !!identity.permissions.canViewAllSessions
}
const role = identity.role || identity.userContext?.role
return role === 'super_admin' || role === 'fallback_admin' || role === 'admin'
|| String(empNo) === 'administrator'
}
const resolveRegistry = () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
}
} catch { /* ignore */ }
return null
}
const resolveSessionCwd = (sessionId, rowHint) => {
if (rowHint?.cwd) return String(rowHint.cwd)
if (rowHint?.header?.cwd) return String(rowHint.header.cwd)
if (typeof resolveLiveCwd === 'function') {
try {
const cwd = resolveLiveCwd(sessionId)
if (cwd) return String(cwd)
} catch { /* ignore */ }
}
return null
}
const workspaceContainsSession = (ws, sessionId) => {
const sid = String(sessionId)
try {
const ids = ws?.sessionIds
if (ids && typeof ids[Symbol.iterator] === 'function') {
for (const id of ids) {
if (String(id) === sid) return true
}
}
} catch { /* ignore */ }
const raw = ws?.record?.sessionIds
if (Array.isArray(raw) && raw.some((id) => String(id) === sid)) return true
return false
}
const isVisibleWorkspace = (identity, ws) => {
if (canSeeAll(identity)) return true
const empNo = empOf(identity)
if (!empNo || !ws) return false
const root = getWorkspaceRoot()
const wid = ws.id ?? ws.workspaceId
const path = ws.path
// View-all off: only the caller's provisioned user workspace — not shared
// project folders (chatgpt/harness/…) or channel roots. Those stay under view-all.
if (userWorkspaces.isUserPath(empNo, path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))) {
return true
}
return false
}
/**
* SEC-13: authoritative owner wins. Foreign owner is never overridden by cwd.
* Unowned legacy sessions may still match via own provisioned cwd / workspace.
*/
const canAccessSession = (sessionId, identity, rowHint) => {
if (!identity || !empOf(identity)) return false
if (canSeeAll(identity)) return true
if (sessionId == null) return false
const empNo = empOf(identity)
const owner = sessionAcl?.getOwner?.(sessionId) || null
if (owner) {
return String(owner) === String(empNo)
}
// No owner stamp — path / workspace membership only (legacy migration path).
const root = getWorkspaceRoot()
const cwd = resolveSessionCwd(sessionId, rowHint)
if (cwd && userWorkspaces.isUserPath(empNo, cwd, root)) return true
const registry = resolveRegistry()
if (!registry || typeof registry.list !== 'function') return false
let workspaces = []
try { workspaces = registry.list() || [] } catch { return false }
for (const ws of workspaces) {
if (!isVisibleWorkspace(identity, ws)) continue
if (workspaceContainsSession(ws, sessionId)) return true
}
return false
}
return {
canSeeAll,
empOf,
resolveRegistry,
resolveSessionCwd,
isVisibleWorkspace,
canAccessSession,
}
}
/**
* Install Host ACL wrappers.
* `getRolesStore` / `rolesStore` is read live on every ACL check so plugin reload
* and preference toggles take effect without re-wrapping Host controllers.
*/
export function installDshAcl(ctx, {
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
rolesStore,
getRolesStore,
getSessionStore,
ensureUserWorkspace,
getWorkspaceRegistry,
}) {
const disposers = []
const resolveRolesStore = () => {
if (typeof getRolesStore === 'function') {
try { return getRolesStore() } catch { return null }
}
if (typeof rolesStore === 'function') {
try { return rolesStore() } catch { return null }
}
return rolesStore || null
}
const resolveSessionStore = () => {
if (typeof getSessionStore === 'function') {
try { return getSessionStore() } catch { return null }
}
return null
}
// Shared live bag: re-install updates this even when Host controllers are already wrapped.
const live = installDshAcl._live || (installDshAcl._live = { access: null })
const rebuildAccess = () => {
live.access = createSessionAccess({
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
rolesStore: resolveRolesStore,
getWorkspaceRegistry: () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
}
} catch { /* ignore */ }
try { return ctx.get('workspaceRegistry') } catch { return null }
},
resolveLiveCwd: (sessionId) => {
try {
const agents = ctx.get('agents')
// F06: use raw lookup — never the ACL-wrapped get (avoids recursion).
const rawGet = agents?.__udsRawGet || agents?.get
const agent = typeof rawGet === 'function' ? rawGet.call(agents, sessionId) : null
return agent?.session?.header?.cwd || null
} catch {
return null
}
},
})
}
rebuildAccess()
const canSeeAll = (identity) => live.access.canSeeAll(identity)
const empOf = (identity) => live.access.empOf(identity)
const resolveRegistry = () => live.access.resolveRegistry()
const isVisibleWorkspace = (identity, ws) => live.access.isVisibleWorkspace(identity, ws)
const canAccessSession = (sessionId, identity, rowHint) => (
live.access.canAccessSession(sessionId, identity, rowHint)
)
/** SEC-10: always read current role/prefs — never trust frozen identity.permissions. */
const livePermissions = (identity) => {
const emp = empOf(identity)
const store = resolveRolesStore()
if (emp && store && typeof store.resolvePermissions === 'function') {
return store.resolvePermissions(emp)
}
return identity?.permissions || {}
}
/**
* A01: every ACL entry must re-validate session + account + auth mode.
* When no session store is wired (unit harness), skip the store check.
*/
const assertPrincipalLive = (identity) => {
if (identity === undefined) return
if (!empOf(identity)) throwForbidden('login_required_session')
const roles = resolveRolesStore()
const emp = empOf(identity)
if (roles && typeof roles.isDisabled === 'function' && roles.isDisabled(emp)) {
throwForbidden('account_disabled')
}
const store = resolveSessionStore()
let sessionRow = null
if (store && typeof store.getBySessionIdSync === 'function') {
const sid = identity.sessionId || identity.userContext?._sessionId
if (!sid || !store.getBySessionIdSync(sid)) throwForbidden('session_revoked')
sessionRow = store.getBySessionIdSync(sid)
if (sessionRow?.empNo && String(sessionRow.empNo) !== String(emp)) throwForbidden('session_revoked')
}
// Prefer live session row kind — incomplete ALS identities (empNo-only) used to
// default administrator→fallback and wipe workspace follow when emergency
// password is unset, even though the session is sealed_box.
const kind = classifyAuthKind(sessionRow || identity, emp)
if (kind === 'sealed_box' && !isLocalAdminBoxConfigured()) {
throwForbidden('local_admin_not_configured')
}
if (kind === 'fallback') {
if (roles && typeof roles.isFallbackEnabled === 'function' && !roles.isFallbackEnabled()) {
throwForbidden('fallback_disabled')
}
const generation = identity.userContext?.fallbackCredVersion
?? sessionRow?.userData?.fallbackCredVersion
if (generation != null && roles?.getFallbackCredVersion?.() !== Number(generation)) {
throwForbidden('fallback_rotated')
}
}
}
// Stamp owner on session create
const offCreated = ctx.on('session/created', (session) => {
try {
const id = session?.id ?? session?.header?.id
const identity = getUserContext()
if (id && identity?.empNo) {
sessionAcl.setOwner(id, identity.empNo)
}
} catch (err) {
ctx.logger?.warn?.('[uds-auth] session stamp failed: %s', err.message)
}
})
disposers.push(() => offCreated?.())
const extractSessionId = (request) => {
if (!request || typeof request !== 'object') return null
return request.address?.sessionId
?? request.sessionId
?? request.id
?? request.childSessionId
?? null
}
// Browser HTTP always enters ALS via withUserContext(null|identity).
// In-process Host callers (WhatsApp/IM, cron fire) never enter ALS → undefined.
// Treat undefined as host-internal and skip UDS ACL (pre-auth behavior).
const assertCanAccess = (request, rowHint) => {
const identity = getUserContext()
if (identity === undefined) return
assertPrincipalLive(identity)
if (canSeeAll(identity)) return
const sessionId = extractSessionId(request)
if (!canAccessSession(sessionId, identity, rowHint)) {
throwForbidden('session_forbidden')
}
}
const assertSessionReadable = (sessionId, rowHint) => {
const identity = getUserContext()
if (identity === undefined) return
assertPrincipalLive(identity)
if (canSeeAll(identity)) return
if (!canAccessSession(sessionId, identity, rowHint)) {
throwForbidden('session_forbidden')
}
}
ctx.inject(['typertGateway'], (gctx) => {
const desktopBootstrap = createDesktopBootstrap({
getService: name => { try { return gctx.get(name) } catch { return null } },
deny: throwForbidden,
})
installGatewayPolicy(gctx.typertGateway, { identity: getUserContext, assertPrincipal: assertPrincipalLive,
assertSession: assertSessionReadable, permissions: livePermissions, deny: throwForbidden, desktopBootstrap })
})
const filterSessionRecords = (records, identity) => (records || []).filter((row) => {
const id = row?.header?.id ?? row?.sessionId ?? row?.id
return id != null && canAccessSession(id, identity, {
cwd: row?.header?.cwd ?? row?.cwd,
header: row?.header,
})
})
// @ mention discovery (SessionReferenceResolver) lists via sessionQuery.listSessions,
// bypassing sessionController ACL — filter the corpus here.
ctx.inject(['sessionQuery'], (qctx) => {
const sq = qctx.sessionQuery
if (!sq || sq.__udsAcl) return
sq.__udsAcl = true
if (typeof sq.listSessions === 'function') {
const origList = sq.listSessions.bind(sq)
sq.listSessions = async (signal) => {
const identity = getUserContext()
if (identity === undefined) return origList(signal)
assertPrincipalLive(identity)
const records = await origList(signal)
if (!empOf(identity)) return []
if (canSeeAll(identity)) return records
return filterSessionRecords(records, identity)
}
}
if (typeof sq.filterSessions === 'function') {
const origFilter = sq.filterSessions.bind(sq)
sq.filterSessions = async (filters, signal) => {
const identity = getUserContext()
if (identity === undefined) return origFilter(filters, signal)
assertPrincipalLive(identity)
const records = await origFilter(filters, signal)
if (!empOf(identity)) return []
if (canSeeAll(identity)) return records
return filterSessionRecords(records, identity)
}
}
if (typeof sq.searchSessions === 'function') {
const origSearch = sq.searchSessions.bind(sq)
sq.searchSessions = async (request, exec) => {
const identity = getUserContext()
if (identity === undefined) return origSearch(request, exec)
assertPrincipalLive(identity)
const page = await origSearch(request, exec)
if (!empOf(identity)) {
return page && typeof page === 'object'
? { ...page, hits: [], items: [] }
: page
}
if (canSeeAll(identity)) return page
if (!page || typeof page !== 'object') return page
const filterHits = (hits) => (hits || []).filter((hit) => {
const id = hit?.sessionId ?? hit?.header?.id ?? hit?.id
return id != null && canAccessSession(id, identity, {
cwd: hit?.cwd ?? hit?.header?.cwd,
header: hit?.header,
})
})
return {
...page,
...(Array.isArray(page.hits) ? { hits: filterHits(page.hits) } : {}),
...(Array.isArray(page.items) ? { items: filterHits(page.items) } : {}),
}
}
}
for (const method of ['readSession', 'readSurface', 'readTitle', 'readTitleSnapshot', 'listEvents', 'observeSession']) {
if (typeof sq[method] !== 'function') continue
const orig = sq[method].bind(sq)
sq[method] = async (sessionId, ...rest) => {
assertSessionReadable(sessionId)
return orig(sessionId, ...rest)
}
}
})
// Belt-and-suspenders: filter @ candidates even if listSessions wrap order changes.
ctx.inject(['sessionReferenceResolver'], (rctx) => {
const resolver = rctx.sessionReferenceResolver
if (!resolver || resolver.__udsAcl) return
resolver.__udsAcl = true
if (typeof resolver.listCandidates === 'function') {
const origList = resolver.listCandidates.bind(resolver)
resolver.listCandidates = async (agent, query, limit, signal) => {
const identity = getUserContext()
if (identity === undefined) return origList(agent, query, limit, signal)
assertPrincipalLive(identity)
const candidates = await origList(agent, query, limit, signal)
if (!empOf(identity)) return []
if (canSeeAll(identity)) return candidates
return (candidates || []).filter((c) => canAccessSession(c?.sessionId, identity, {
cwd: c?.cwd,
}))
}
}
if (typeof resolver.prepare === 'function') {
const origPrepare = resolver.prepare.bind(resolver)
resolver.prepare = async (agent, content, references, signal) => {
const identity = getUserContext()
if (identity !== undefined) {
assertPrincipalLive(identity)
if (!empOf(identity)) throwForbidden('login_required_session')
if (!canSeeAll(identity)) {
for (const ref of references || []) {
const sid = ref?.sessionId
if (sid != null && !canAccessSession(sid, identity)) {
throwForbidden('session_forbidden')
}
}
}
}
return origPrepare(agent, content, references, signal)
}
}
})
ctx.inject(['sessionController'], (sctx) => {
const sc = sctx.sessionController
if (!sc || sc.__udsAcl) return
sc.__udsAcl = true
const assertCreateTargetAllowed = async (req, identity) => {
// R04: live permissions after demotion — never trust frozen identity.permissions.
const perms = livePermissions(identity)
if (canSeeAll(identity) || perms.canCreateWorkspace) return
const empNo = empOf(identity)
const root = getWorkspaceRoot()
if (req.workspaceId !== undefined) {
const registry = resolveRegistry()
const ws = registry?.get?.(req.workspaceId)
if (!ws || !isVisibleWorkspace(identity, ws)) {
throwForbidden('session_workspace_only')
}
return
}
if (req.cwd !== undefined) {
if (!userWorkspaces.isUserPath(empNo, req.cwd, root)) {
throwForbidden('session_workspace_only')
}
}
}
const filterItems = (items, identity) => (items || []).filter((row) => {
const id = row?.sessionId ?? row?.id
return id != null && canAccessSession(id, identity, row)
})
// Deeper wrap: ApiSessionList.list (cold summaries)
if (sc.listState && typeof sc.listState.list === 'function' && !sc.listState.__udsAcl) {
sc.listState.__udsAcl = true
const origStateList = sc.listState.list.bind(sc.listState)
sc.listState.list = async (signal) => {
const identity = getUserContext()
if (identity === undefined) return origStateList(signal)
assertPrincipalLive(identity)
const items = await origStateList(signal)
if (!empOf(identity)) return []
if (canSeeAll(identity)) return items
return filterItems(items, identity)
}
if (typeof sc.listState.search === 'function') {
const origStateSearch = sc.listState.search.bind(sc.listState)
sc.listState.search = async (query, signal) => {
const identity = getUserContext()
if (identity === undefined) return origStateSearch(query, signal)
assertPrincipalLive(identity)
const value = await origStateSearch(query, signal)
if (!empOf(identity)) return { items: [], hasMore: false }
if (canSeeAll(identity)) return value
return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row))
}
}
}
const origList = sc.list.bind(sc)
sc.list = async (request, signal) => {
const identity = getUserContext()
if (identity === undefined) return origList(request, signal)
assertPrincipalLive(identity)
const value = await origList(request, signal)
if (!empOf(identity)) return { items: [] }
if (canSeeAll(identity)) return value
return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row))
}
const origSearch = sc.search.bind(sc)
sc.search = async (request, signal) => {
const identity = getUserContext()
if (identity === undefined) return origSearch(request, signal)
assertPrincipalLive(identity)
const value = await origSearch(request, signal)
if (!empOf(identity)) return { items: [], hasMore: false }
if (canSeeAll(identity)) return value
return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row))
}
const origCreate = sc.create.bind(sc)
sc.create = async (request) => {
const identity = getUserContext()
// WhatsApp / IM harness creates sessions in-process with no UDS ALS.
if (identity === undefined) {
return origCreate(request || {})
}
if (!empOf(identity)) throwForbidden('login_required_create_session')
assertPrincipalLive(identity)
let req = { ...(request || {}) }
await assertCreateTargetAllowed(req, identity)
// SEC-15: never fall through to Host default cwd when provisioning fails.
if (req.workspaceId === undefined && req.cwd === undefined) {
const ensured = await ensureUserWorkspace(identity.empNo)
if (ensured?.workspaceId) {
req = { ...req, workspaceId: ensured.workspaceId }
} else if (ensured?.path) {
req = { ...req, cwd: ensured.path }
} else {
throwForbidden('workspace_provision_failed')
}
}
if (req.workspaceId === undefined && req.cwd === undefined) {
throwForbidden('workspace_provision_failed')
}
if (req.workspaceId != null && req.cwd != null) {
// Ambiguous dual target — keep workspaceId, drop cwd.
const { cwd: _drop, ...rest } = req
req = rest
}
const result = await origCreate(req)
const sid = result?.sessionId ?? result?.id
if (sid) sessionAcl.setOwner(sid, identity.empNo)
return result
}
const waitForIdentity = async (ms = 800) => {
let identity = getUserContext()
// Host-internal: do not burn 800ms waiting for a browser cookie that will never appear.
if (identity === undefined) return identity
if (empOf(identity)) return identity
// Wait briefly for connection-bound ALS only — never borrow another user's identity.
const deadline = Date.now() + ms
while (!empOf(identity) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 40))
identity = getUserContext()
}
return identity
}
const wrapSessionMethod = (methodName) => {
if (typeof sc[methodName] !== 'function') return
const orig = sc[methodName].bind(sc)
// follow is an async generator: assert inside so we can await identity.
if (methodName === 'follow') {
sc.follow = async function* (request, signal) {
const identity = await waitForIdentity()
assertCanAccess(request)
// Long-lived generator: pin ALS so history frames keep empNo after awaits.
if (identity !== undefined) enterUserContext(identity)
for await (const frame of orig(request, signal)) {
// SEC-10: re-check before each push after demote/logout.
assertCanAccess(request)
yield frame
}
}
return
}
sc[methodName] = async (request, signal) => {
const identity = await waitForIdentity()
assertCanAccess(request)
if (identity !== undefined) enterUserContext(identity)
return orig(request, signal)
}
}
wrapSessionMethod('page')
wrapSessionMethod('follow')
wrapSessionMethod('prompt')
wrapSessionMethod('rename')
wrapSessionMethod('cancel')
wrapSessionMethod('updateQueue')
wrapSessionMethod('attachment')
wrapSessionMethod('selectModel')
wrapSessionMethod('projections')
if (typeof sc.control === 'function') {
const originalControl = sc.control.bind(sc)
sc.control = async function* (signal) {
for await (const frame of originalControl(signal)) {
const identity = getUserContext()
if (identity === undefined) { yield frame; continue }
assertPrincipalLive(identity)
if (frame.type === 'baseline') {
const projections = Object.fromEntries(Object.entries(frame.value?.projections || {})
.filter(([id]) => canAccessSession(id, identity)))
yield { ...frame, value: { ...frame.value, projections } }
} else if (frame.type === 'projection' && canAccessSession(frame.sessionId, identity)) {
yield frame
}
}
}
}
if (typeof sc.openWorkspacePath === 'function') {
const origOpenPath = sc.openWorkspacePath.bind(sc)
sc.openWorkspacePath = async (request, signal) => {
const identity = getUserContext()
if (identity === undefined) return origOpenPath(request, signal)
if (!empOf(identity)) throwForbidden('login_required_session')
const perms = livePermissions(identity)
if (!canSeeAll(identity) && !perms.canCreateWorkspace) {
const path = request?.path
if (!path || !userWorkspaces.isUserPath(empOf(identity), path, getWorkspaceRoot())) {
throwForbidden('workspace_path_only')
}
}
return origOpenPath(request, signal)
}
}
if (typeof sc.fork === 'function') {
const origFork = sc.fork.bind(sc)
sc.fork = async (request, signal) => {
assertCanAccess(request)
const result = await origFork(request, signal)
const identity = getUserContext()
const sid = result?.sessionId ?? result?.id
if (sid && empOf(identity)) sessionAcl.setOwner(sid, empOf(identity))
return result
}
}
})
ctx.inject(['workspaceController'], (wctx) => {
const wc = wctx.workspaceController
if (!wc || wc.__udsAcl) return
wc.__udsAcl = true
const origCreate = wc.create.bind(wc)
wc.create = async (request) => {
const identity = getUserContext()
if (identity?._internalProvision) return origCreate(request)
// undefined = trusted Host (runAsHost); null/missing = anonymous browser — deny.
if (identity === undefined) return origCreate(request)
assertPrincipalLive(identity)
if (!empOf(identity)) throwForbidden('login_required_create_workspace')
if (!livePermissions(identity).canCreateWorkspace) {
throwForbidden('workspace_create_forbidden')
}
return origCreate(request)
}
/** SEC-11: guard workspace write / archive / pin Remote methods. */
const projectIdList = (identity, ids) => {
if (!Array.isArray(ids)) return ids
if (canSeeAll(identity)) return ids
return ids.filter((id) => id != null && canAccessSession(String(id), identity))
}
const projectMutationResult = (identity, value) => {
if (value == null || typeof value !== 'object') return value
if (canSeeAll(identity)) return value
const next = { ...value }
for (const key of [
'archivedSessionIds', 'pinnedSessionIds', 'sessionIds', 'ids', 'items',
]) {
if (Array.isArray(next[key])) {
if (key === 'items') {
next[key] = next[key].filter((row) => {
const id = row?.sessionId ?? row?.id
return id == null || canAccessSession(String(id), identity, row)
})
} else {
next[key] = projectIdList(identity, next[key])
}
}
}
return next
}
const wrapWorkspaceWrite = (methodName, { sessionScoped = false } = {}) => {
if (typeof wc[methodName] !== 'function') return
const orig = wc[methodName].bind(wc)
wc[methodName] = async (...args) => {
const identity = getUserContext()
if (identity === undefined) return orig(...args)
assertPrincipalLive(identity)
if (!empOf(identity)) throwForbidden('login_required_workspace')
if (sessionScoped) {
const sid = args[0]?.sessionId ?? args[0]?.id ?? args[0]
if (sid != null && !canSeeAll(identity) && !canAccessSession(sid, identity)) {
throwForbidden('session_forbidden')
}
} else if (!livePermissions(identity).canCreateWorkspace) {
throwForbidden('workspace_create_forbidden')
}
const result = await orig(...args)
return projectMutationResult(identity, result)
}
}
wrapWorkspaceWrite('rename')
wrapWorkspaceWrite('delete')
wrapWorkspaceWrite('insertBefore')
wrapWorkspaceWrite('insertSessionBefore', { sessionScoped: true })
wrapWorkspaceWrite('archiveSession', { sessionScoped: true })
wrapWorkspaceWrite('unarchiveSession', { sessionScoped: true })
wrapWorkspaceWrite('pinSession', { sessionScoped: true })
wrapWorkspaceWrite('unpinSession', { sessionScoped: true })
const canSeeAllWorkspaces = (identity) => {
if (!identity) return false
return canSeeAll(identity)
}
const allowWorkspace = (identity, ws) => {
if (!identity?.empNo && !identity?.userContext?.empNo) return false
if (canSeeAllWorkspaces(identity)) return true
const empNo = identity.empNo || identity.userContext?.empNo
const root = getWorkspaceRoot()
const wid = ws?.workspaceId ?? ws?.id
if (userWorkspaces.isUserPath(empNo, ws?.path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))) {
return true
}
return false
}
const filterSessionIdList = (identity, ids) => {
if (!Array.isArray(ids)) return ids
if (canSeeAllWorkspaces(identity)) return ids
return ids.filter((id) => id != null && canAccessSession(String(id), identity))
}
const projectWorkspaceItem = (identity, ws) => {
if (!ws || typeof ws !== 'object') return ws
if (canSeeAllWorkspaces(identity)) return ws
if (!Array.isArray(ws.sessionIds)) return ws
return {
...ws,
sessionIds: filterSessionIdList(identity, ws.sessionIds),
}
}
const filterBaseline = (identity, baseline) => {
if (!baseline) return baseline
const next = { ...baseline }
if (Array.isArray(baseline.items)) {
next.items = baseline.items
.filter((ws) => allowWorkspace(identity, ws))
.map((ws) => projectWorkspaceItem(identity, ws))
}
// SEC-12: do not leak foreign archived/pinned session ids.
if (Array.isArray(baseline.archivedSessionIds)) {
next.archivedSessionIds = filterSessionIdList(identity, baseline.archivedSessionIds)
}
if (Array.isArray(baseline.pinnedSessionIds)) {
next.pinnedSessionIds = filterSessionIdList(identity, baseline.pinnedSessionIds)
}
return next
}
const filterFrame = (identity, frame) => {
if (!frame) return frame
if (frame.type === 'baseline') {
return { ...frame, value: filterBaseline(identity, frame.value) }
}
if (frame.type === 'upsert') {
if (!allowWorkspace(identity, frame.workspace)) return null
return {
...frame,
workspace: projectWorkspaceItem(identity, frame.workspace),
}
}
if (frame.type === 'order') {
if (canSeeAllWorkspaces(identity)) return frame
const empNo = identity?.empNo || identity?.userContext?.empNo
const allowed = new Set()
const mapped = userWorkspaces.get(empNo)?.workspaceId
if (mapped != null) allowed.add(String(mapped))
return {
...frame,
workspaceIds: (frame.workspaceIds || []).filter((id) => allowed.has(String(id))),
}
}
// R08: DSH remove frames carry workspaceId (not sessionId).
if (frame.type === 'remove') {
const wid = frame.workspaceId ?? frame.id ?? frame.value?.workspaceId
if (wid != null) {
const ws = { workspaceId: wid, id: wid, path: frame.path }
return allowWorkspace(identity, ws) ? frame : null
}
}
// SEC-12: archive/pin frames — drop if session not accessible.
const sid = frame.sessionId ?? frame.id ?? frame.value?.sessionId
if (sid != null && ['archive', 'unarchive', 'pin', 'unpin', 'archived', 'pinned'].includes(frame.type)) {
return canAccessSession(String(sid), identity) ? frame : null
}
if (Array.isArray(frame.archivedSessionIds) || Array.isArray(frame.pinnedSessionIds)) {
return {
...frame,
...(Array.isArray(frame.archivedSessionIds)
? { archivedSessionIds: filterSessionIdList(identity, frame.archivedSessionIds) }
: {}),
...(Array.isArray(frame.pinnedSessionIds)
? { pinnedSessionIds: filterSessionIdList(identity, frame.pinnedSessionIds) }
: {}),
}
}
// Unknown frame types: default deny (do not pass through).
if (!['baseline', 'upsert', 'order'].includes(frame.type)) {
return null
}
return frame
}
const origFollow = wc.follow.bind(wc)
const emptyWorkspaceBaseline = () => ({
type: 'baseline',
value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] },
})
const holdUntilAbort = async (signal) => {
if (!signal || signal.aborted) return
await new Promise((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true })
})
}
wc.follow = async function* (signal) {
// Identity is fixed at WS upgrade (ticket/bridge). Waiting on ALS cannot
// turn an anonymous mux into an admin — Desktop must soft-reconnect.
//
// Critical Desktop bug: returning with ZERO frames makes
// WorkspaceStateStream throw a non-Carrier Error
// ("ended before its opening snapshot"), which is terminal — later
// authenticated reconnects never repaint and the sidebar stays「未分组」
// even though a fresh follow with a ticket returns all workspaces.
const identity = getUserContext()
// undefined = trusted Host (runAsHost) — full feed.
if (identity === undefined) {
yield* origFollow(signal)
return
}
// Anonymous browser: empty snapshot + hold until reconnect aborts us.
if (!empOf(identity)) {
yield emptyWorkspaceBaseline()
await holdUntilAbort(signal)
return
}
// R04: never bypass projection for admin-at-subscribe — re-check every frame after demotion.
let sentBaseline = false
for await (const frame of origFollow(signal)) {
const liveId = getUserContext() || identity
try {
assertPrincipalLive(liveId)
} catch {
if (!sentBaseline) yield emptyWorkspaceBaseline()
return
}
if (frame?.type === 'baseline') sentBaseline = true
if (canSeeAllWorkspaces(liveId)) {
yield frame
continue
}
const next = filterFrame(liveId, frame)
if (next) {
if (next.type === 'baseline') sentBaseline = true
yield next
}
}
}
})
// settings mutate gate
ctx.inject(['settings'], (sctx) => {
const settings = sctx.settings
if (!settings || settings.__udsAcl) return
settings.__udsAcl = true
for (const method of ['mutate', 'update', 'replace', 'write']) {
if (typeof settings[method] !== 'function') continue
const orig = settings[method].bind(settings)
settings[method] = async (...args) => {
const identity = getUserContext()
if (identity === undefined) return orig(...args)
assertPrincipalLive(identity)
if (!empOf(identity) || !livePermissions(identity).canAccessSettings) {
throwForbidden('forbidden_settings')
}
return orig(...args)
}
}
})
// credentialsController — deny browser users from reading/writing Host credentials
ctx.inject(['credentialsController'], (cctx) => {
const cc = cctx.credentialsController
if (!cc || cc.__udsAcl) return
cc.__udsAcl = true
const guard = (methodName) => {
if (typeof cc[methodName] !== 'function') return
const orig = cc[methodName].bind(cc)
cc[methodName] = async (...args) => {
const identity = getUserContext()
if (identity === undefined) return orig(...args)
assertPrincipalLive(identity)
if (!empOf(identity) || !livePermissions(identity).canAccessSettings) {
throwForbidden('forbidden_settings')
}
return orig(...args)
}
}
for (const m of ['get', 'set', 'list', 'delete', 'update', 'read', 'write']) {
guard(m)
}
})
// R02: jobController — request.sessionId is a resource id, not caller identity.
ctx.inject(['jobController'], (jctx) => {
const jc = jctx.jobController
if (!jc || jc.__udsAcl) return
jc.__udsAcl = true
for (const method of ['kill', 'list', 'follow']) {
if (typeof jc[method] !== 'function') continue
const orig = jc[method].bind(jc)
if (method === 'follow' || method === 'list') {
jc[method] = async function* (request, signal) {
assertCanAccess(request)
for await (const frame of orig(request, signal)) {
try { assertCanAccess(request) } catch { return }
yield frame
}
}
} else {
jc[method] = async (request, signal) => {
assertCanAccess(request)
return orig(request, signal)
}
}
}
})
// A03: terminalController — preserve AsyncIterable for streams; wrap retain.
ctx.inject(['terminalController'], (tctx) => {
const tc = tctx.terminalController
if (!tc || tc.__udsAcl) return
tc.__udsAcl = true
const terminalSessionId = (agentOrReq, rest) => (
agentOrReq?.session?.header?.id
?? agentOrReq?.sessionId
?? agentOrReq?.id
?? (typeof agentOrReq === 'string' ? agentOrReq : null)
?? rest?.[0]?.sessionId
?? null
)
const assertTerminalAccess = (agentOrReq, rest = []) => {
const sid = terminalSessionId(agentOrReq, rest)
if (sid != null) assertSessionReadable(sid)
else {
const identity = getUserContext()
if (identity !== undefined) assertPrincipalLive(identity)
}
}
if (typeof tc.list === 'function') {
const origList = tc.list.bind(tc)
tc.list = (sessionId, ...rest) => {
assertSessionReadable(sessionId)
return origList(sessionId, ...rest)
}
}
// Stream methods must return AsyncIterable synchronously (Gateway does not await).
for (const method of ['follow', 'retain']) {
if (typeof tc[method] !== 'function') continue
const orig = tc[method].bind(tc)
tc[method] = function udsAuthTerminalStream(agentOrReq, ...rest) {
try {
assertTerminalAccess(agentOrReq, rest)
} catch {
// Deny as empty stream so callers iterating frames get no data (V11).
return (async function* () {})()
}
const out = orig(agentOrReq, ...rest)
// If Host returns a thenable accidentally, still expose async iteration.
const iterate = async function* () {
const stream = typeof out?.then === 'function' ? await out : out
for await (const frame of stream) {
try { assertTerminalAccess(agentOrReq, rest) } catch { return }
yield frame
}
}
if (out && typeof out[Symbol.asyncIterator] === 'function' && typeof out.then !== 'function') {
return (async function* () {
for await (const frame of out) {
try { assertTerminalAccess(agentOrReq, rest) } catch { return }
yield frame
}
})()
}
return iterate()
}
}
for (const method of ['create', 'resize', 'write', 'close', 'rename', 'dispose']) {
if (typeof tc[method] !== 'function') continue
const orig = tc[method].bind(tc)
tc[method] = async (agentOrReq, ...rest) => {
assertTerminalAccess(agentOrReq, rest)
return orig(agentOrReq, ...rest)
}
}
})
// R02: agents.get — Typert / Agent lookup by sessionId under UDS ACL.
ctx.inject(['agents'], (actx) => {
const agents = actx.agents
if (!agents || agents.__udsAcl) return
agents.__udsAcl = true
if (typeof agents.get === 'function') {
const origGet = agents.get.bind(agents)
// F06: preserve unbound raw get for resolveLiveCwd (no ACL recursion).
agents.__udsRawGet = origGet
agents.get = (sessionId, ...rest) => {
const identity = getUserContext()
if (identity !== undefined) assertSessionReadable(sessionId)
return origGet(sessionId, ...rest)
}
}
})
// A02: authorize on real target path/key after resolve — not only displayPath.
// Soft-deny for /api/file: Host serveFile only maps FsError→403; RemoteError escapes.
ctx.inject(['fs'], (fctx) => {
const fs = fctx.fs
if (!fs || fs.__udsAcl) return
fs.__udsAcl = true
const pathFromTarget = (target) => {
if (typeof target === 'string') return target
if (!target || typeof target !== 'object') return null
// Prefer realpath / targetKey over displayPath (junction-safe).
return target.targetKey || target.realpath || target.realPath
|| target.path || target.displayPath || target.href || null
}
const fileAllowed = (path) => {
const identity = getUserContext()
if (identity === undefined) return true
assertPrincipalLive(identity)
if (canSeeAll(identity)) return true
const emp = empOf(identity)
const root = getWorkspaceRoot()
return !!(path && userWorkspaces.isUserPath(emp, path, root))
}
const deniedStub = (displayPath) => ({
displayPath: displayPath || '',
targetKey: displayPath || '',
path: displayPath || '',
__udsAuthDenied: true,
})
/**
* F09: Prefer Host FsError → serveFile maps to 403. When the Host package is
* unavailable (unit/probe), soft-deny without leaking foreign bytes.
*/
const softDeny = (method, displayPath = '') => {
try {
const { FsError } = require('@deepseek-ai/dsh-fs')
throw new FsError('file_forbidden', 'FS_PERMISSION_DENIED')
} catch (err) {
if (err?.code === 'FS_PERMISSION_DENIED') throw err
}
if (method === 'readBytes') return new Uint8Array(0)
if (method === 'readText') return ''
if (method === 'stat') return undefined
if (method === 'open') throwForbidden('file_forbidden')
return deniedStub(displayPath)
}
for (const method of ['resolve', 'stat', 'readBytes', 'readText', 'open']) {
if (typeof fs[method] !== 'function') continue
const orig = fs[method].bind(fs)
fs[method] = async (target, ...rest) => {
if (target?.__udsAuthDenied) return softDeny(method, target.displayPath || '')
if (method === 'resolve') {
const input = typeof target === 'string' ? target : pathFromTarget(target)
if (input && !fileAllowed(input)) return deniedStub(input)
const resolved = await orig(target, ...rest)
const real = pathFromTarget(resolved) || input
if (real && !fileAllowed(real)) {
return deniedStub(resolved?.displayPath || input || real)
}
return resolved
}
const path = pathFromTarget(target)
if (path && !fileAllowed(path)) return softDeny(method, path)
return orig(target, ...rest)
}
}
})
// directory picker: admin-class (live canCreateWorkspace); wrap capability() too (SEC-11)
ctx.inject(['directoryPicker'], (dctx) => {
const dp = dctx.directoryPicker
if (!dp || dp.__udsAcl) return
dp.__udsAcl = true
const assertCanCreateWorkspace = () => {
const identity = getUserContext()
if (identity === undefined) return
assertPrincipalLive(identity)
if (!empOf(identity)) throwForbidden('login_required_workspace')
if (!livePermissions(identity).canCreateWorkspace) {
throwForbidden('workspace_create_forbidden')
}
}
const wrapDpMethod = (obj, methodName) => {
if (!obj || typeof obj[methodName] !== 'function') return
const orig = obj[methodName].bind(obj)
obj[methodName] = async (...args) => {
assertCanCreateWorkspace()
return orig(...args)
}
}
wrapDpMethod(dp, 'pick')
wrapDpMethod(dp, 'list')
wrapDpMethod(dp, 'createDirectory')
if (typeof dp.capability === 'function') {
const origCap = dp.capability.bind(dp)
dp.capability = (...args) => {
assertCanCreateWorkspace()
const cap = origCap(...args)
if (cap && typeof cap === 'object') {
wrapDpMethod(cap, 'list')
wrapDpMethod(cap, 'createDirectory')
wrapDpMethod(cap, 'pick')
}
return cap
}
}
})
return () => {
for (const d of disposers) {
try { d() } catch { /* ignore */ }
}
}
}