Merge pull request #567: fix(cases): only refuse the cases folder and its direct children for a custom-folder case

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:16:42 +02:00
commit 7504a2bc8b
5 files changed
+50 -6

No files matched your search

@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
Create New → custom folder no longer refuses every folder below the cases folder. Only the cases folder itself and its direct children are refused, because those are the only ones Codeman lists as local cases (a second registration would show them twice). With the cases folder set to a broad root such as `/mnt/user/Scripts` (`CODEMAN_CASES_PATH`), a folder like `/mnt/user/Scripts/GitHub/me/new-project` can now be created and linked. The refusal message now names the cases folder.
+1 -1
View File
@@ -1020,7 +1020,7 @@ The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
- It must not be the cases directory (the caller's own or the shared one) or a direct child of it, since those are what `GET /api/cases` lists as local cases: a case there is a plain create without `path`. A folder further down is allowed. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
+13 -4
View File
@@ -12,8 +12,10 @@
* 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;
* - it must not be, or be inside, a cases directory: 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;
* - 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;
* - 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);
@@ -98,9 +100,16 @@ export function blockedReason(
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";
}
// 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.
for (const dir of ctx.casesDirs ?? []) {
if (isWithin(absPath, dir)) {
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
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)`;
}
}
return null;
+16 -1
View File
@@ -54,14 +54,29 @@ describe('blockedReason', () => {
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
it('refuses a cases directory and its direct children, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases');
// Two messages: the cases folder is not "in" itself, and a child that does not exist yet is not
// "already" listed.
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/cases folder itself/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/directly in the cases folder/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).not.toMatch(/already/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('allows a folder deeper than a direct child of the cases directory (it is not listed as a local case)', () => {
// CODEMAN_CASES_PATH can be a broad root such as /mnt/user/Scripts; GET /api/cases lists only its
// direct children, so a folder further down can be linked without appearing twice.
const broad = { ...c, casesDirs: ['/mnt/user/Scripts'] };
expect(blockedReason('/mnt/user/Scripts/GitHub', broad)).toMatch(/plain Create New/);
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454', broad)).toBeNull();
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454/app', broad)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
@@ -183,6 +183,21 @@ describe('POST /api/cases with a custom path', () => {
}
});
it('creates and links a folder two levels under the cases directory (not listed as a local case)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'parent-dir'), { recursive: true });
try {
const target = join(realpathSync(join(CASES_DIR(), 'parent-dir')), 'nested');
const res = await create(app, { name: 'nested', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'nested', path: target });
expect(linked()).toEqual({ nested: target });
expect(existsSync(join(target, 'CLAUDE.md'))).toBe(true);
} finally {
safeRmHomeTree(join(CASES_DIR(), 'parent-dir'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });