fix(multiuser): close cross-user web-layer scoping holes found in review

The opt-in multi-user feature's only enforcement is web-layer scoping
(all sessions share one OS account). An adversarial review found 8 critical
+ 7 high cross-user holes that defeated it, plus mediums; all fixed here.
Single-user (flag-off) behavior stays byte-identical apart from documented
consistency deltas.

Ownership / confinement:
- DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail
- quick-start, cron (create+fire), scheduled runs confine workingDir to the
  owner's space; case link/docker-link/docker-import confine the host path
- resolveCasePath no longer resolves linked cases for non-admins; foreign
  remote/docker cases are skipped (fall through to the caller's own local case)
- history, subagents/workflows, mux-sessions, orchestrator, cron run-history,
  away-digest, and remote/docker host reads are owner- or admin-scoped

Permission policy (section 6.3):
- non-granted users are downgraded at every spawn site incl. legacy
  /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire
  gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed

Auth / store:
- verify-first login throttle (a correct password is never locked out),
  /ws terminal subject to the change-password lockbox, cookie fast-path
  re-validates identity live, role/grant changes revoke sessions, admin delete
  runs the last-admin guard before any teardown
- users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad
  read can't overwrite all accounts; unique per-process temp write path

Event streams:
- debounced session:updated + batched task:updated, clipboard, and push
  notifications route by owner (fail closed); getLightState hides machine-wide
  globalStats from non-admins

Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint,
and test:ci all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 12:33:12 +02:00
parent c3b0dc345b
commit ccb3afc9ee
29 changed files with 757 additions and 230 deletions
+21 -8
View File
@@ -16,7 +16,7 @@ import { CronJobSchema } from '../web/schemas.js';
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
import { sessionCapacityState } from '../web/route-helpers.js';
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import {
DEFAULT_BLOCKED_TREES,
@@ -27,6 +27,7 @@ import { validateSessionFilePath } from '../web/route-helpers.js';
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
import type { GeminiConfig } from '../types/session.js';
import type { CronJobInput } from './cron-input.js';
/** The subset of the route context the cron depends on. */
@@ -331,6 +332,12 @@ export class CronService {
return this.failRun(job, run, 'workingDir does not exist');
}
// Section 6.3: defense-in-depth workingDir confinement re-check at FIRE time against the
// owner's CURRENT space (complements the create/update gate). No-op in single-user / unset owner.
if (!(await isWorkingDirAllowedForUsername(job.owner, job.workingDir))) {
return this.failRun(job, run, 'workingDir is outside the owner workspace');
}
// Recurring jobs: close the still-open session created by this job's
// previous run before launching the next (default ON, opt-out via
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
@@ -348,13 +355,11 @@ export class CronService {
return this.failRun(job, run, `Owner's per-user session limit reached`);
}
// Section 6.3: shell mode / launchCommand are arbitrary host-account execution.
// Re-check the owner's grant at FIRE time (it may have been revoked since create).
if (job.agentType === 'shell' || job.launchCommand) {
const allowed = await canUsernameRunPrivilegedCommands(job.owner);
if (!allowed) {
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
}
// Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked
// since create). Gates shell/launchCommand AND clamps the external-CLI bypass below.
const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner);
if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) {
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
}
// Create + start the session (mirrors the quick-start route flow).
@@ -366,6 +371,13 @@ export class CronService {
const claudeModeConfig = await this.deps.getClaudeModeConfig();
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
// Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined)
// would default a non-granted owner to `--approval-mode yolo` (classifier-free) —
// materialize auto_edit for a non-granted gemini owner, mirroring the route clamp
// (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent
// config already defaults to the safe sandbox, so no clamp is needed there.
const geminiConfig: GeminiConfig | undefined =
mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined;
session = new Session({
workingDir: job.workingDir,
mode,
@@ -376,6 +388,7 @@ export class CronService {
model,
claudeMode: effectiveClaudeMode,
allowedTools: claudeModeConfig.allowedTools,
geminiConfig,
owner: job.owner,
});
this.deps.addSession(session);