Skip to content
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ _Avoid_: main view, page, layout mode
A user-visible, Project-owned Excalidraw document composed of independently editable shapes, text, and connectors that serves as the shared source for Agent generation and user editing. Subsequent Agent changes apply to the current document rather than regenerating a separate copy; the capability is available across Scenes.
_Avoid_: Scientific Figure, generated image, flattened flowchart, hidden artifact, Research Scene tool

**Flow Diagram Document Version**:
An opaque identity of the exact current contents of an Editable Flow Diagram, used to detect whether a save or Agent edit is still based on that document. It is not ordered and does not represent history.
_Avoid_: Flow Diagram Revision, timestamp, save counter, version number, Project commit

**Flow Diagram Revision**:
A durable snapshot of an Editable Flow Diagram captured immediately before an Agent modifies it and retained independently of the user's Project version control. It is available to Agent operations and automatic recovery without exposing version management or manual rollback controls to the user.
_Avoid_: Project commit, copied backup file, Excalidraw undo entry, user-facing version history, full-Project snapshot
Expand Down
2 changes: 1 addition & 1 deletion src/main/academic-style-revision-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('Academic Style Revision Skill', () => {
expect(fs.readFileSync(path.join(skillDir as string, 'SKILL.md'), 'utf-8')).toBe(markdown);
expect(fs.existsSync(path.join(skillDir as string, 'scripts'))).toBe(false);
expect(fs.readFileSync(path.join(skillDir as string, 'PROVENANCE.md'), 'utf-8')).toContain('MIT');
});
}, 15_000);

it('publishes scope, fidelity, coverage, and report safety contracts without executable capabilities', () => {
const markdown = getAcademicStyleRevisionSkillMarkdown();
Expand Down
2 changes: 1 addition & 1 deletion src/main/at-mention/candidate-lister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('candidate-lister', () => {
const result = listCandidates(tempDir);
expect(result.candidates.length).toBe(5000);
expect(result.truncated).toBe(true);
}, 15_000);
}, 30_000);

// Phase 08.3 fix #6: symlink-traversal guard.
it('drops symlink paths that resolve outside the project root', () => {
Expand Down
102 changes: 67 additions & 35 deletions src/main/cdf-file-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { pathToFileURL } from 'node:url';
import {
contentTypeForPath,
createCdfFileResponse,
isPathWithinRoots,
parseRangeHeader,
resolveCdfFilePath,
} from './cdf-file-protocol';
Expand Down Expand Up @@ -36,40 +35,6 @@ describe('resolveCdfFilePath', () => {
});
});

describe('isPathWithinRoots', () => {
// Regression (#204 回归): under standard:true Chromium folds the first path
// segment into the URL host and lowercases it, so every macOS request arrives
// as /users/... while allowedRoots hold /Users/.... On a case-insensitive
// filesystem both address the same file, so containment must not be
// case-sensitive — otherwise every historical image/audio 403s.
it('accepts a host-casefolded path on a case-insensitive filesystem', () => {
expect(isPathWithinRoots(
'/users/suntc/Library/Application Support/cdf/default-project/.cdf/artifacts/images/a.png',
['/Users/suntc/Library/Application Support/cdf'],
true,
)).toBe(true);
});

it('still rejects casing differences in case-sensitive mode', () => {
expect(isPathWithinRoots(
'/users/suntc/Library/Application Support/cdf/a.png',
['/Users/suntc/Library/Application Support/cdf'],
false,
)).toBe(false);
});

it('still rejects escapes and unrelated roots regardless of casing mode', () => {
expect(isPathWithinRoots('/Users/suntc/other/a.png', ['/Users/suntc/Library'], true)).toBe(false);
expect(isPathWithinRoots('/Users/suntc/Library/../.ssh/id_rsa', ['/Users/suntc/Library'], true)).toBe(false);
expect(isPathWithinRoots('/Users/suntc/LibraryEvil/a.png', ['/Users/suntc/Library'], true)).toBe(false);
});

it('defaults to case-insensitive containment on macOS/Windows', () => {
const expected = process.platform === 'darwin' || process.platform === 'win32';
expect(isPathWithinRoots('/users/x/a.png', ['/Users/x'])).toBe(expected);
});
});

describe('parseRangeHeader', () => {
it('returns null when there is no Range header', () => {
expect(parseRangeHeader(null, 1000)).toBeNull();
Expand Down Expand Up @@ -199,6 +164,73 @@ describe('createCdfFileResponse', () => {
expect(res.status).toBe(403);
});

it('rejects a sibling directory that differs from the allowed root only by casing when both can exist', async () => {
const allowedRoot = path.join(tempDir, 'Allowed');
const siblingRoot = path.join(tempDir, 'allowed');
fs.mkdirSync(allowedRoot);
if (fs.existsSync(siblingRoot)) {
// The current volume is case-insensitive; the macOS host-fold regression below
// exercises that filesystem identity. Case-sensitive CI/volumes continue here.
return;
}
fs.mkdirSync(siblingRoot);
const siblingFile = path.join(siblingRoot, 'clip.mp4');
fs.writeFileSync(siblingFile, CONTENT);

const res = await createCdfFileResponse({
url: cdfUrl(siblingFile),
rangeHeader: null,
allowedRoots: [allowedRoot],
});

expect(res.status).toBe(403);
});

it('returns 403 for a path whose directory only shares the allowed root prefix', async () => {
const allowedRoot = path.join(tempDir, 'Library');
const prefixedSibling = path.join(tempDir, 'LibraryEvil');
fs.mkdirSync(allowedRoot);
fs.mkdirSync(prefixedSibling);
const siblingFile = path.join(prefixedSibling, 'clip.mp4');
fs.writeFileSync(siblingFile, CONTENT);

const res = await createCdfFileResponse({
url: cdfUrl(siblingFile),
rangeHeader: null,
allowedRoots: [allowedRoot],
});

expect(res.status).toBe(403);
});

it.runIf(process.platform !== 'win32')(
'returns 403 for ordinary and Range requests through a symlink that points outside the root',
async () => {
const allowedRoot = path.join(tempDir, 'allowed');
const outsidePath = path.join(tempDir, 'outside.mp4');
const linkedPath = path.join(allowedRoot, 'linked.mp4');
fs.mkdirSync(allowedRoot);
fs.writeFileSync(outsidePath, CONTENT);
fs.symlinkSync(outsidePath, linkedPath);

const [ordinaryResponse, rangeResponse] = await Promise.all([
createCdfFileResponse({
url: cdfUrl(linkedPath),
rangeHeader: null,
allowedRoots: [allowedRoot],
}),
createCdfFileResponse({
url: cdfUrl(linkedPath),
rangeHeader: 'bytes=0-5',
allowedRoots: [allowedRoot],
}),
]);

expect(ordinaryResponse.status).toBe(403);
expect(rangeResponse.status).toBe(403);
},
);

// Regression (#204 回归): simulate Chromium's host casefolding — the URL path
// casing differs from the allowedRoots casing, but the case-insensitive macOS
// filesystem still resolves the same file. Must serve 200, not 403.
Expand Down
92 changes: 66 additions & 26 deletions src/main/cdf-file-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,33 +145,59 @@ export interface CdfFileRequest {
allowedRoots: string[];
}

interface CanonicalPath {
path: string;
exists: boolean;
}

/**
* macOS/Windows 的默认文件系统大小写不敏感:`/users/…` 与 `/Users/…` 指向同一文件。
* standard scheme 下 Chromium 会把第一段路径折进 URL host 并小写化
* (`cdf-file:///Users/…` → `cdf-file://users/…`),因此白名单包含性判断必须
* 同样按大小写不敏感比较,否则所有 `/Users/…` 资源都会被 403。
* 把路径解析成文件系统认可的真实路径。目标不存在时,从最近的已有父目录继续解析,
* 这样既能让允许根内的缺失文件返回 404,也不会把 `..` 或父目录软链接误判为允许路径。
*/
const CASE_INSENSITIVE_FILESYSTEM = process.platform === 'darwin' || process.platform === 'win32';
async function resolveCanonicalPath(filePath: string): Promise<CanonicalPath | null> {
let current = path.resolve(filePath);
const missingSegments: string[] = [];

while (true) {
try {
const canonicalParent = await fs.promises.realpath(current);
return {
path: path.join(canonicalParent, ...missingSegments),
exists: missingSegments.length === 0,
};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') return null;

const parent = path.dirname(current);
if (parent === current) return null;
missingSegments.unshift(path.basename(current));
current = parent;
}
}
}

/**
* 判断解析后的绝对路径是否落在任一允许根内。先 `path.resolve` 折叠 `..`,
* 再用 `path.relative` 做包含性判断,杜绝 `/root/../../etc/passwd` 之类逃逸。
* `caseInsensitive` 默认跟随平台文件系统语义(darwin/win32 不敏感)。
* `realpath` 已经按当前卷和目录的真实大小写返回路径,因此这里只做逐段精确比较。
* Windows 盘符本身不区分大小写,但启用 per-directory case sensitivity 的目录段必须区分。
*/
export function isPathWithinRoots(
filePath: string,
allowedRoots: string[],
caseInsensitive: boolean = CASE_INSENSITIVE_FILESYSTEM,
): boolean {
const resolved = path.resolve(filePath);
const target = caseInsensitive ? resolved.toLowerCase() : resolved;
return allowedRoots.some((root) => {
if (!root) return false;
const normalizedRoot = path.resolve(root);
const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
const rel = path.relative(comparableRoot, target);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
});
function isCanonicalPathWithinRoot(target: string, root: string): boolean {
const normalizedTarget = path.resolve(target);
const normalizedRoot = path.resolve(root);
const targetRoot = path.parse(normalizedTarget).root;
const allowedRoot = path.parse(normalizedRoot).root;
const sameVolume =
process.platform === 'win32'
? targetRoot.toLowerCase() === allowedRoot.toLowerCase()
: targetRoot === allowedRoot;
if (!sameVolume) return false;

const targetSegments = normalizedTarget.slice(targetRoot.length).split(path.sep).filter(Boolean);
const rootSegments = normalizedRoot.slice(allowedRoot.length).split(path.sep).filter(Boolean);
return (
rootSegments.length <= targetSegments.length
&& rootSegments.every((segment, index) => segment === targetSegments[index])
);
}

/**
Expand All @@ -181,14 +207,28 @@ export function isPathWithinRoots(
*/
export async function createCdfFileResponse(request: CdfFileRequest): Promise<Response> {
const filePath = resolveCdfFilePath(request.url);
const canonicalTarget = await resolveCanonicalPath(filePath);
if (!canonicalTarget) {
return new Response('File not found', { status: 404 });
}

if (!isPathWithinRoots(filePath, request.allowedRoots)) {
const canonicalRoots = (
await Promise.all(
request.allowedRoots
.filter(Boolean)
.map(async (root) => (await resolveCanonicalPath(root))?.path ?? null)
)
).filter((root): root is string => root !== null);
if (!canonicalRoots.some((root) => isCanonicalPathWithinRoot(canonicalTarget.path, root))) {
return new Response('Forbidden', { status: 403 });
}
if (!canonicalTarget.exists) {
return new Response('File not found', { status: 404 });
}

let stat: fs.Stats;
try {
stat = await fs.promises.stat(filePath);
stat = await fs.promises.stat(canonicalTarget.path);
} catch {
return new Response('File not found', { status: 404 });
}
Expand All @@ -207,7 +247,7 @@ export async function createCdfFileResponse(request: CdfFileRequest): Promise<Re
}

if (range) {
const stream = fs.createReadStream(filePath, { start: range.start, end: range.end });
const stream = fs.createReadStream(canonicalTarget.path, { start: range.start, end: range.end });
return new Response(nodeStreamToWeb(stream), {
status: 206,
headers: {
Expand All @@ -219,7 +259,7 @@ export async function createCdfFileResponse(request: CdfFileRequest): Promise<Re
});
}

const stream = fs.createReadStream(filePath);
const stream = fs.createReadStream(canonicalTarget.path);
return new Response(nodeStreamToWeb(stream), {
status: 200,
headers: {
Expand Down
69 changes: 44 additions & 25 deletions src/main/commands/collectors/skill.test.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { getBuiltInSkillDirsMock, getScopePathMock, resolveSkillSourcePlanMock, resolveSkillCatalogMock } = vi.hoisted(() => ({
getBuiltInSkillDirsMock: vi.fn(() => ['/tmp/built-in/knowledge-base']),
getScopePathMock: vi.fn(() => '/tmp/global-skills'),
resolveSkillSourcePlanMock: vi.fn(() => ({ config: { version: 1, additionalSkillDirectories: [] }, sources: [], warnings: [] })),
resolveSkillCatalogMock: vi.fn((): any => ({ skills: [], warnings: [] })),
const { resolveProjectSkillCatalogMock } = vi.hoisted(() => ({
resolveProjectSkillCatalogMock: vi.fn((): any => ({ skills: [], warnings: [] })),
}));

vi.mock('../../deepagent/skill-manager', () => ({
getBuiltInSkillDirs: getBuiltInSkillDirsMock,
getScopePath: getScopePathMock,
}));
vi.mock('../../deepagent/skills-runtime/skill-sources', () => ({
resolveSkillSourcePlan: resolveSkillSourcePlanMock,
resolveSkillCatalog: resolveSkillCatalogMock,
}));
vi.mock('../../deepagent/skill-catalog', async () => {
const sources = await vi.importActual<
typeof import('../../deepagent/skills-runtime/skill-sources')
>('../../deepagent/skills-runtime/skill-sources');
return {
resolveProjectSkillCatalog: resolveProjectSkillCatalogMock,
getSkillSourceLabel: sources.getSkillSourceLabel,
};
});

import { collectSkillCommands } from './skill';

describe('collectSkillCommands', () => {
beforeEach(() => {
vi.clearAllMocks();
resolveSkillCatalogMock.mockReturnValue({ skills: [], warnings: [] });
resolveProjectSkillCatalogMock.mockReturnValue({ skills: [], warnings: [] });
});

it('resolves the live catalog from Built-in and Global sources when no snapshot exists', async () => {
await expect(collectSkillCommands('/tmp/project')).resolves.toEqual([]);
it('resolves the live catalog through the Skill Catalog when no snapshot exists', async () => {
await expect(collectSkillCommands('/tmp/project', { includeNestedProjectSkills: true }))
.resolves.toEqual([]);

expect(resolveSkillSourcePlanMock).toHaveBeenCalledWith('/tmp/project', {
builtInSkillDirs: ['/tmp/built-in/knowledge-base'],
userSkillsDir: '/tmp/global-skills',
includeNestedProjectSkills: undefined,
});
expect(resolveProjectSkillCatalogMock).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({ includeNestedProjectSkills: true }),
);
});

it('uses the frozen Conversation Skill Snapshot without resolving a live catalog', async () => {
Expand All @@ -46,11 +44,11 @@ describe('collectSkillCommands', () => {
}] });

expect(commands).toEqual([expect.objectContaining({ name: 'review', target: 'project:review' })]);
expect(resolveSkillSourcePlanMock).not.toHaveBeenCalled();
expect(resolveProjectSkillCatalogMock).not.toHaveBeenCalled();
});

it('maps a Project Skill to an attributable command', async () => {
resolveSkillCatalogMock.mockReturnValue({ skills: [{
resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{
name: 'simplify', description: 'Simplify code', sourceKind: 'project',
sourcePath: '/tmp/project/.cdf/skills', skillPath: '/tmp/project/.cdf/skills/simplify/SKILL.md',
modelDiscovery: 'full', userInvocable: true, argumentHint: '<file>',
Expand All @@ -66,7 +64,7 @@ describe('collectSkillCommands', () => {
});

it('maps a Global Skill to a global attributable command', async () => {
resolveSkillCatalogMock.mockReturnValue({ skills: [{
resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{
name: 'explore', description: 'Explore the repository', sourceKind: 'user',
sourcePath: '/tmp/global-skills', skillPath: '/tmp/global-skills/explore/SKILL.md',
modelDiscovery: 'full', userInvocable: true,
Expand All @@ -81,8 +79,29 @@ describe('collectSkillCommands', () => {
});
});

it('maps an Enterprise Skill to the canonical Managed Skill attribution', async () => {
resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{
name: 'compliance', qualifiedName: 'acme:compliance', qualifier: 'acme',
description: 'Apply managed compliance policy', sourceKind: 'enterprise',
sourcePath: '/tmp/managed-skills', skillPath: '/tmp/managed-skills/compliance/SKILL.md',
modelDiscovery: 'full', userInvocable: true,
}], warnings: [] });

const commands = await collectSkillCommands('/tmp/project');

expect(commands[0]).toMatchObject({
name: 'acme:compliance',
qualifiedName: 'acme:compliance',
skillName: 'compliance',
skillSourceKind: 'enterprise',
source: 'skill:global',
target: 'enterprise:acme:compliance',
sourceLabel: 'Managed Skill',
});
});

it('omits Skills whose author disables explicit invocation', async () => {
resolveSkillCatalogMock.mockReturnValue({ skills: [{
resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{
name: 'internal', description: 'Internal workflow', sourceKind: 'project',
sourcePath: '/tmp/project/.cdf/skills', skillPath: '/tmp/project/.cdf/skills/internal/SKILL.md',
modelDiscovery: 'full', userInvocable: false,
Expand Down
Loading
Loading