2026-10-03 13:54:16 +08:00
/**
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
* judged before anything is created:
*
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
* a case this accepts is one a session can actually start in;
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
2026-10-05 14:48:45 +02:00
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
* around it;
2026-10-18 08:05:39 +02:00
* - it must not be a cases directory or a direct child of one: those are what `GET /api/cases` lists
* as local cases, so a case there is a plain Create New, and the same folder listed both as a local
* case and as a linked one would make deleting it remove files. A folder further down is not
* listed and is allowed;
2026-10-03 13:54:16 +08:00
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
* - it must not be a symlink.
*
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
* so it can be tested without a disk.
*
* @module web/case-path
*/
import { promises as fs } from 'node:fs' ;
import { basename , dirname , join , resolve , sep } from 'node:path' ;
import { isValidWorkingDir } from './schemas.js' ;
2026-10-05 20:00:03 +02:00
import { describeUnknownPath , probePath } from '../utils/index.js' ;
2026-10-03 13:54:16 +08:00
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
const BLOCKED_SYSTEM_ROOTS = [
'/bin' ,
'/boot' ,
'/dev' ,
'/etc' ,
'/lib' ,
'/lib32' ,
'/lib64' ,
'/proc' ,
'/run' ,
'/sbin' ,
'/sys' ,
'/usr' ,
];
/** Home-relative trees that hold credentials or other tools' own configuration. */
const BLOCKED_HOME_DIRS = [ '.ssh' , '.gnupg' , '.aws' , '.kube' , '.docker' , '.claude' , '.codex' , '.gemini' ];
export interface NewCasePathContext {
home : string ;
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
dataDir : string ;
2026-10-05 14:48:45 +02:00
/**
* The cases directories (the caller's own and the shared one). A folder in one of them is already
* listed as a local case, so it must not be registered as a linked one too.
*/
casesDirs? : readonly string [];
2026-10-03 13:54:16 +08:00
}
export type NewCasePathResult =
| { ok : true ; path : string ; existedEmpty : boolean }
2026-10-05 20:00:03 +02:00
| { ok : false ; code : 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS' | 'UNREACHABLE' ; reason : string };
2026-10-03 13:54:16 +08:00
const isWithin = ( child : string , root : string ) : boolean =>
child === root || child . startsWith ( root . endsWith ( sep ) ? root : root + sep );
/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
export function expandHome ( raw : string , home : string ) : string {
if ( raw === '~' ) return home ;
if ( raw . startsWith ( '~/' )) return join ( home , raw . slice ( 2 ));
return raw ;
}
2026-10-05 14:48:45 +02:00
/**
* Why a case may not live at this (already absolute and normalised) path, or null. `systemRoots`
* defaults to the system trees as spelled; pass their symlink-resolved forms to judge a resolved path.
*/
export function blockedReason (
absPath : string ,
ctx : NewCasePathContext ,
systemRoots : readonly string [] = BLOCKED_SYSTEM_ROOTS
) : string | null {
2026-10-03 13:54:16 +08:00
if ( absPath === sep ) return 'The filesystem root cannot be a case' ;
2026-10-05 14:48:45 +02:00
for ( const root of systemRoots ) {
2026-10-03 13:54:16 +08:00
if ( isWithin ( absPath , root )) return ` ${ root } is a system directory` ;
}
if ( absPath === ctx . home ) return 'The home folder itself cannot be a case; pick a folder inside it' ;
for ( const dir of BLOCKED_HOME_DIRS ) {
if ( isWithin ( absPath , join ( ctx . home , dir ))) return `~/ ${ dir } holds credentials or another tool's configuration` ;
}
if ( isWithin ( absPath , ctx . dataDir )) return "Codeman's own data folder cannot be a case" ;
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
// the one this process uses.
if ( absPath . startsWith ( ctx . home + sep )) {
const firstSegment = absPath . slice ( ctx . home . length + 1 ). split ( sep )[ 0 ];
if ( /^\.codeman/ . test ( firstSegment )) return "Codeman's own data folder cannot be a case" ;
}
2026-10-09 07:41:28 +08:00
// Only the cases folder itself and its DIRECT children are listed as local cases (GET /api/cases reads
// that one level), so only those would be registered twice. A deeper folder is not listed and is a
// perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts,
// refusing everything below it blocked most of the tree.
2026-10-05 14:48:45 +02:00
for ( const dir of ctx . casesDirs ?? []) {
2026-10-18 08:05:39 +02:00
if ( absPath === dir ) {
return `That is the cases folder itself ( ${ dir } ); create a case inside it with plain Create New (no custom folder)` ;
}
if ( dirname ( absPath ) === dir ) {
return `That folder is directly in the cases folder ( ${ dir } ), where it is listed as a local case; create it there with plain Create New (no custom folder)` ;
2026-10-05 14:48:45 +02:00
}
}
2026-10-03 13:54:16 +08:00
return null ;
}
2026-10-05 14:48:45 +02:00
/** `p` with its symlinks resolved, or `p` itself when it does not exist (or cannot be read). */
async function realpathOr ( p : string ) : Promise < string > {
try {
return await fs . realpath ( p );
} catch {
return p ;
}
}
/** The context and system roots with their symlinks resolved, for judging a resolved path. */
async function resolvedPolicy ( ctx : NewCasePathContext ) : Promise < [ NewCasePathContext , string []] > {
const [ home , dataDir , casesDirs , systemRoots ] = await Promise . all ([
realpathOr ( ctx . home ),
realpathOr ( ctx . dataDir ),
Promise . all (( ctx . casesDirs ?? []). map ( realpathOr )),
Promise . all ( BLOCKED_SYSTEM_ROOTS . map ( realpathOr )),
]);
return [{ home , dataDir , casesDirs }, systemRoots ];
}
2026-10-03 13:54:16 +08:00
/**
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
* Never creates anything.
*/
export async function prepareNewCasePath ( raw : string , ctx : NewCasePathContext ) : Promise < NewCasePathResult > {
const typed = raw . trim ();
if ( ! typed ) return { ok : false , code : 'INVALID' , reason : 'Enter a folder path' };
const expanded = expandHome ( typed , ctx . home );
if ( ! isValidWorkingDir ( expanded )) {
return {
ok : false ,
code : 'INVALID' ,
reason : 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)' ,
};
}
const target = resolve ( expanded );
const typedBlock = blockedReason ( target , ctx );
if ( typedBlock ) return { ok : false , code : 'BLOCKED' , reason : typedBlock };
2026-10-05 20:00:03 +02:00
// Bounded first: the parent can sit on a network mount that stopped answering, where the
// realpath/stat/lstat/readdir below would each hold a threadpool worker until it returns.
// It is one folder the user named, so the probe may pass the bulk cap (never the ceiling).
const parentState = await probePath ( dirname ( target ), { pastCap : true });
if ( parentState === 'absent' ) {
return { ok : false , code : 'NOT_FOUND' , reason : `The parent folder ${ dirname ( target ) } does not exist` };
}
if ( parentState === 'unknown' ) {
return {
ok : false ,
code : 'UNREACHABLE' ,
reason : describeUnknownPath ( 'The parent folder' , dirname ( target ), { pastCap : true }),
};
}
2026-10-03 13:54:16 +08:00
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
let realParent : string ;
try {
realParent = await fs . realpath ( dirname ( target ));
if ( ! ( await fs . stat ( realParent )). isDirectory ()) {
return { ok : false , code : 'INVALID' , reason : ` ${ dirname ( target ) } is not a folder` };
}
} catch {
return { ok : false , code : 'NOT_FOUND' , reason : `The parent folder ${ dirname ( target ) } does not exist` };
}
const real = join ( realParent , basename ( target ));
2026-10-05 14:48:45 +02:00
// The resolved path against the roots as given AND as resolved: with home reached through a link, a
// link to <real home>/.ssh is only caught by the resolved home; on macOS /etc is /private/etc.
const [ resolvedCtx , resolvedSystemRoots ] = await resolvedPolicy ( ctx );
const realBlock = blockedReason ( real , ctx ) ?? blockedReason ( real , resolvedCtx , resolvedSystemRoots );
2026-10-03 13:54:16 +08:00
if ( realBlock ) return { ok : false , code : 'BLOCKED' , reason : realBlock };
try {
const st = await fs . lstat ( real );
if ( st . isSymbolicLink ()) return { ok : false , code : 'INVALID' , reason : ` ${ target } is a symbolic link` };
if ( ! st . isDirectory ()) return { ok : false , code : 'INVALID' , reason : ` ${ target } exists and is not a folder` };
if (( await fs . readdir ( real )). length > 0 ) {
return {
ok : false ,
code : 'EXISTS' ,
reason : ` ${ target } already has files in it. Use "Link Existing" for a project that already exists` ,
};
}
return { ok : true , path : real , existedEmpty : true };
} catch ( err ) {
if (( err as NodeJS . ErrnoException ). code === 'ENOENT' ) return { ok : true , path : real , existedEmpty : false };
return { ok : false , code : 'INVALID' , reason : `Cannot read ${ target } : ${ ( err as Error ). message } ` };
}
}