Files
Codeman/src/web/routes/scheduled-routes.ts
T
Codeman maintainer ccb3afc9ee 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>
2026-07-20 12:33:12 +02:00

85 lines
3.4 KiB
TypeScript

/**
* @fileoverview Scheduled run routes.
* CRUD operations for scheduled autonomous runs with session lifecycle management.
*/
import { FastifyInstance } from 'fastify';
import { statSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { ScheduledRunSchema } from '../schemas.js';
import {
parseBody,
getAuthUser,
ownerFor,
isWorkingDirAllowed,
canAccessOwned,
resolveCasesDir,
} from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
app.get('/api/scheduled', async (req) => {
// Multi-user: non-admins see only their own runs (no-op in single-user).
const user = getAuthUser(req);
return Array.from(ctx.scheduledRuns.values()).filter((r) => canAccessOwned(user, r.owner));
});
app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
// Multi-user: confine the run's workingDir to the caller's own case space.
// The spawned Session (--dangerously-skip-permissions by default) trusts this
// dir; without confinement a non-admin could point it at another user's files.
// No-op for admins / single-user (isWorkingDirAllowed returns true).
if (workingDir && !isWorkingDirAllowed(getAuthUser(req), workingDir)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is not within your allowed workspace');
}
// Validate workingDir exists and is a directory
if (workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
}
// Multi-user: default a missing workingDir to the user's own cases dir rather
// than the server's cwd. Single-user keeps process.cwd() (byte-identical).
const effectiveWorkingDir = workingDir || (isMultiUserMode() ? resolveCasesDir(getAuthUser(req)) : process.cwd());
const run = await ctx.startScheduledRun(prompt, effectiveWorkingDir, durationMinutes ?? 60, ownerFor(req));
return { run };
});
app.delete('/api/scheduled/:id', async (req) => {
const { id } = req.params as { id: string };
const run = ctx.scheduledRuns.get(id);
// NOT_FOUND (not FORBIDDEN) for a foreign run so existence isn't leaked; no-op
// for admins / single-user (canAccessOwned returns true).
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
}
await ctx.stopScheduledRun(id);
return {};
});
app.get('/api/scheduled/:id', async (req) => {
const { id } = req.params as { id: string };
const run = ctx.scheduledRuns.get(id);
// Owner-scoped read: a foreign run reads as NOT_FOUND (no-op in single-user).
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
}
return run;
});
}