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
+22 -2
View File
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import { getErrorMessage, type PlanItem } from './types.js';
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
// Re-export for backward compatibility
export type { PlanItem };
@@ -130,18 +130,28 @@ export class PlanOrchestrator {
private taskDescription = '';
private researchModel: string;
private plannerModel: string;
// Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the
// internal research/planner one-shots. Left undefined = today's single-user behavior
// (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()).
private claudeMode?: ClaudeMode;
private owner?: string;
private allowedTools?: string;
constructor(
mux: TerminalMultiplexer,
workingDir: string = process.cwd(),
outputDir?: string,
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string }
) {
this.mux = mux;
this.workingDir = workingDir;
this.outputDir = outputDir;
this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
this.claudeMode = security?.claudeMode;
this.owner = security?.owner;
this.allowedTools = security?.allowedTools;
}
private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void {
@@ -424,6 +434,12 @@ export class PlanOrchestrator {
mux: this.mux,
useMux: false,
mode: 'claude',
// Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a
// non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined
// (single-user, not threaded) is byte-identical to today (Session keeps its default).
claudeMode: this.claudeMode,
allowedTools: this.allowedTools,
owner: this.owner,
});
this.runningSessions.add(session);
@@ -580,6 +596,10 @@ export class PlanOrchestrator {
mux: this.mux,
useMux: false,
mode: 'claude',
// Section 6.3: same permission-mode/owner threading as the research one-shot above.
claudeMode: this.claudeMode,
allowedTools: this.allowedTools,
owner: this.owner,
});
this.runningSessions.add(session);