fix(settings): scan all projects, add selectors, fix traversal vuln

Project Rules page:
- Rewrite project discovery to mirror memory.ts: scan ~/.claude/projects/
  for project IDs, resolve each to a real path via session .jsonl heads
  (cwd field) + sanitized FS search, using the shared sanitizePath from
  utils/path.js (the prior naive sanitize never matched real project IDs)
- Add project selector dropdown; rules below switch dynamically

Skill activation UI:
- Center toggle button labels (justify-center + min-w)
- Show project selector + active status when scope is "project"

Security:
- Fix path-traversal write in POST /api/project-rules/create: the new
  user-controlled filename flowed into path.join unchecked, allowing
  "../../../x.md" to write outside the project dir. Now reject traversal,
  absolute paths, and null bytes with 400 via containsPathTraversal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
你的姓名 2026-06-18 04:58:16 +08:00
parent 4c9a8f57ca
commit 2f594cf7e3
9 changed files with 424 additions and 176 deletions

View File

@ -6,6 +6,7 @@ import { CodeViewer } from '../chat/CodeViewer'
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
import { useUIStore } from '../../stores/uiStore'
import { skillsApi } from '../../api/skills'
import { api } from '../../api/client'
import { useSessionStore } from '../../stores/sessionStore'
const META_PRIORITY = [
@ -244,20 +245,50 @@ export function SkillDetail() {
type ActivationScope = 'off' | 'global' | 'project'
type ProjectChoice = {
id: string
label: string
projectPath: string | null
isCurrent: boolean
}
function SkillActivationScope({ skillName }: { skillName: string }) {
const t = useTranslation()
const [globalSkills, setGlobalSkills] = useState<string[]>([])
const [projectSkills, setProjectSkills] = useState<string[]>([])
const [projects, setProjects] = useState<ProjectChoice[]>([])
const [selectedProjectPath, setSelectedProjectPath] = useState<string | undefined>(undefined)
const [saving, setSaving] = useState(false)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeSession = sessions.find((s) => s.id === activeSessionId)
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
// Load the project list (reuse the project-rules endpoint) so the user can
// pick WHICH project the skill applies to under 'project' scope.
useEffect(() => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
api.get<{ projects: ProjectChoice[] }>(`/api/project-rules${query}`)
.then((res) => {
const resolved = res.projects.filter((p) => p.projectPath)
setProjects(resolved)
setSelectedProjectPath((prev) => {
if (prev && resolved.some((p) => p.projectPath === prev)) return prev
const current = resolved.find((p) => p.isCurrent)
return current?.projectPath ?? resolved[0]?.projectPath ?? undefined
})
})
.catch(() => {})
}, [cwd])
// Load activation state for global + the currently selected project.
useEffect(() => {
skillsApi.getActiveSkills('global').then(res => setGlobalSkills(res.activeSkills)).catch(() => {})
skillsApi.getActiveSkills('project', cwd).then(res => setProjectSkills(res.activeSkills)).catch(() => {})
}, [cwd])
}, [])
useEffect(() => {
skillsApi.getActiveSkills('project', selectedProjectPath).then(res => setProjectSkills(res.activeSkills)).catch(() => {})
}, [selectedProjectPath])
const currentScope: ActivationScope =
projectSkills.includes(skillName) ? 'project'
@ -267,7 +298,6 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
const handleChange = async (scope: ActivationScope) => {
setSaving(true)
try {
// Remove from both first
const newGlobal = globalSkills.filter(s => s !== skillName)
const newProject = projectSkills.filter(s => s !== skillName)
@ -279,7 +309,7 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
await Promise.all([
skillsApi.setActiveSkills(newGlobal, 'global'),
skillsApi.setActiveSkills(newProject, 'project', cwd),
skillsApi.setActiveSkills(newProject, 'project', selectedProjectPath),
])
setGlobalSkills(newGlobal)
setProjectSkills(newProject)
@ -290,6 +320,23 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
}
}
// When the user switches the project dropdown, re-evaluate whether the skill
// should be re-applied to the newly selected project (if scope was 'project').
const handleProjectSwitch = async (projectPath: string) => {
const wasProjectScoped = currentScope === 'project'
setSelectedProjectPath(projectPath)
// Fetch the new project's active skills to reflect its status immediately.
try {
const res = await skillsApi.getActiveSkills('project', projectPath)
setProjectSkills(res.activeSkills)
// If the skill was project-scoped on the previous project, do NOT auto-move
// it — let the user explicitly click 'project' again for the new one.
void wasProjectScoped
} catch {
// ignore
}
}
const options: { value: ActivationScope; label: string; desc: string }[] = [
{ value: 'off', label: t('settings.skills.activation.off'), desc: t('settings.skills.activation.offDesc') },
{ value: 'global', label: t('settings.skills.activation.global'), desc: t('settings.skills.activation.globalDesc') },
@ -318,22 +365,55 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
key={opt.value}
onClick={() => handleChange(opt.value)}
disabled={saving}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
className={`flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-sm min-w-[88px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
currentScope === opt.value
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)] font-medium'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
} disabled:opacity-50`}
title={opt.desc}
>
<span className={`w-2 h-2 rounded-full ${currentScope === opt.value ? 'bg-[var(--color-brand)]' : 'bg-[var(--color-text-muted)]'}`} />
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${currentScope === opt.value ? 'bg-[var(--color-brand)]' : 'bg-[var(--color-text-muted)]'}`} />
{opt.label}
</button>
))}
</div>
{/* Project selector — only relevant when applying at 'project' scope. */}
{currentScope === 'project' && projects.length > 0 && (
<div className="mt-3 flex items-center gap-2 flex-wrap">
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.skills.activation.appliesTo')}
</span>
<select
value={selectedProjectPath ?? ''}
onChange={(e) => handleProjectSwitch(e.target.value)}
disabled={saving}
className="text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 max-w-[70%] text-[var(--color-text-primary)]"
>
{projects.map((p) => (
<option key={p.id} value={p.projectPath ?? ''}>
{p.isCurrent ? '★ ' : ''}{shortenProjectPath(p.label)}
</option>
))}
</select>
<span className="inline-flex items-center gap-1 text-xs text-[var(--color-brand)]">
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
{t('settings.skills.activation.active')}
</span>
</div>
)}
</section>
)
}
function shortenProjectPath(p: string): string {
const parts = p.split(/[/\\]/).filter(Boolean)
if (parts.length <= 2) return p
const sep = p.includes('\\') ? '\\' : '/'
return '…' + sep + parts.slice(-2).join(sep)
}
function TreeView({
nodes,
selectedPath,

View File

@ -841,6 +841,8 @@ export const en = {
'settings.skills.activation.globalDesc': 'Auto-loaded in every conversation across all projects',
'settings.skills.activation.project': 'Project',
'settings.skills.activation.projectDesc': 'Auto-loaded only in conversations for the current project',
'settings.skills.activation.appliesTo': 'Applies to:',
'settings.skills.activation.active': 'Active',
// Settings > Memory
'settings.tab.memory': 'Memory',

View File

@ -841,6 +841,8 @@ export const jp: Record<TranslationKey, string> = {
'settings.skills.activation.globalDesc': 'すべてのプロジェクトの会話で自動読み込み',
'settings.skills.activation.project': 'プロジェクト',
'settings.skills.activation.projectDesc': '現在のプロジェクトの会話でのみ自動読み込み',
'settings.skills.activation.appliesTo': '適用先:',
'settings.skills.activation.active': '有効',
// Settings > Memory
'settings.tab.memory': 'メモリ',

View File

@ -841,6 +841,8 @@ export const kr: Record<TranslationKey, string> = {
'settings.skills.activation.globalDesc': '모든 프로젝트의 대화에서 자동 로드',
'settings.skills.activation.project': '프로젝트',
'settings.skills.activation.projectDesc': '현재 프로젝트의 대화에서만 자동 로드',
'settings.skills.activation.appliesTo': '적용 대상:',
'settings.skills.activation.active': '활성',
// Settings > Memory
'settings.tab.memory': '메모리',

View File

@ -841,6 +841,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.activation.globalDesc': '所有專案的每次對話都自動載入',
'settings.skills.activation.project': '專案',
'settings.skills.activation.projectDesc': '僅當前專案的對話自動載入',
'settings.skills.activation.appliesTo': '套用到:',
'settings.skills.activation.active': '已啟用',
// Settings > Memory
'settings.tab.memory': '記憶',

View File

@ -843,6 +843,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.activation.globalDesc': '所有项目的每次对话都自动加载',
'settings.skills.activation.project': '项目',
'settings.skills.activation.projectDesc': '仅当前项目的对话自动加载',
'settings.skills.activation.appliesTo': '应用到:',
'settings.skills.activation.active': '已激活',
// Settings > Memory
'settings.tab.memory': '记忆',

View File

@ -30,6 +30,7 @@ export function ProjectRulesSettings() {
const t = useTranslation()
const [data, setData] = useState<ProjectRulesResponse | null>(null)
const [loading, setLoading] = useState(true)
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeSession = sessions.find((s) => s.id === activeSessionId)
@ -41,6 +42,12 @@ export function ProjectRulesSettings() {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
const res = await api.get<ProjectRulesResponse>(`/api/project-rules${query}`)
setData(res)
// Default-select the current project (or first project) once loaded.
setSelectedProjectId((prev) => {
if (prev && res.projects.some((p) => p.id === prev)) return prev
const current = res.projects.find((p) => p.isCurrent)
return current?.id ?? res.projects[0]?.id ?? null
})
} catch {
setData(null)
} finally {
@ -56,7 +63,7 @@ export function ProjectRulesSettings() {
try {
await getDesktopHost().shell.openPath(filePath)
} catch {
// fallback: ignore
// ignore
}
}
@ -79,6 +86,8 @@ export function ProjectRulesSettings() {
if (!data) return null
const selectedProject = data.projects.find((p) => p.id === selectedProjectId) ?? null
return (
<div className="space-y-6">
<div>
@ -86,7 +95,7 @@ export function ProjectRulesSettings() {
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t('settings.projectRules.description')}</p>
</div>
{/* User-level rules (global) */}
{/* User-level rules (always shown, apply globally) */}
<Section title={t('settings.projectRules.userFile')} description={t('settings.projectRules.userFileDesc')}>
{data.userFiles.map((file) => (
<FileRow key={file.path} file={file} onOpen={handleOpen} onCreate={() => {
@ -94,71 +103,66 @@ export function ProjectRulesSettings() {
else handleCreate('user')
}} t={t} />
))}
{data.userFiles.every(f => !f.exists) && (
<div className="flex gap-2 mt-2">
<Button size="sm" variant="secondary" onClick={() => handleCreate('user')}>
<span className="material-symbols-outlined text-base mr-1">add</span>
~/.claude/CLAUDE.md
</Button>
</div>
)}
</Section>
{/* Per-project rules */}
{data.projects.map((project) => (
<ProjectSection
key={project.id}
project={project}
onOpen={handleOpen}
onCreate={handleCreate}
t={t}
/>
))}
{/* Project selector + dynamic project rules */}
<div className="border border-[var(--color-border)] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-sm font-medium text-[var(--color-text)]">{t('settings.projectRules.projectFile')}</h3>
{data.projects.length > 0 && (
<select
value={selectedProjectId ?? ''}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 max-w-[60%] text-[var(--color-text)]"
>
{data.projects.map((p) => (
<option key={p.id} value={p.id}>
{p.isCurrent ? '★ ' : ''}{shortenPath(p.label)}
</option>
))}
</select>
)}
</div>
{selectedProject ? (
<div className="space-y-1">
<p className="text-xs text-[var(--color-text-muted)] font-mono truncate" title={selectedProject.label}>
{selectedProject.label}
</p>
{selectedProject.files.map((file) => (
<FileRow
key={file.path}
file={file}
onOpen={handleOpen}
onCreate={() => {
const projectCwd = selectedProject.projectPath || undefined
if (file.label === 'CLAUDE.md') handleCreate('project-root', projectCwd)
else if (file.label === '.claude/CLAUDE.md') handleCreate('project', projectCwd)
else if (file.label === 'CLAUDE.local.md') handleCreate('local', projectCwd)
}}
t={t}
/>
))}
<div className="flex gap-2 mt-2">
<Button size="sm" variant="secondary" onClick={() => handleCreate('project-rules', selectedProject.projectPath || undefined, 'new-rule.md')}>
<span className="material-symbols-outlined text-base mr-1">add</span>
.claude/rules/
</Button>
</div>
</div>
) : (
<p className="text-xs text-[var(--color-text-muted)]">{t('settings.projectRules.notFound')}</p>
)}
</div>
</div>
)
}
function ProjectSection({ project, onOpen, onCreate, t }: {
project: ProjectEntry
onOpen: (path: string) => void
onCreate: (scope: string, cwd?: string, filename?: string) => void
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}) {
const projectCwd = project.projectPath || undefined
const hasExistingFiles = project.files.some(f => f.exists)
const title = project.isCurrent
? `${t('settings.projectRules.projectFile')} (current)`
: t('settings.projectRules.projectFile')
return (
<Section
title={title}
description={project.label}
>
{project.files.filter(f => f.exists).map((file) => (
<FileRow key={file.path} file={file} onOpen={onOpen} onCreate={() => {}} t={t} />
))}
{project.files.filter(f => !f.exists).map((file) => (
<FileRow key={file.path} file={file} onOpen={onOpen} onCreate={() => {
if (file.label === 'CLAUDE.md') onCreate('project-root', projectCwd)
else if (file.label === '.claude/CLAUDE.md') onCreate('project', projectCwd)
else if (file.label === 'CLAUDE.local.md') onCreate('local', projectCwd)
}} t={t} />
))}
{!hasExistingFiles && (
<div className="flex gap-2 mt-2">
<Button size="sm" variant="secondary" onClick={() => onCreate('project-root', projectCwd)}>
<span className="material-symbols-outlined text-base mr-1">add</span>
CLAUDE.md
</Button>
<Button size="sm" variant="secondary" onClick={() => onCreate('project-rules', projectCwd, 'new-rule.md')}>
<span className="material-symbols-outlined text-base mr-1">add</span>
.claude/rules/
</Button>
</div>
)}
</Section>
)
function shortenPath(p: string): string {
// Show last 2 segments for readability, full path is in title/tooltip below.
const parts = p.split(/[/\\]/).filter(Boolean)
if (parts.length <= 2) return p
return '…' + (p.includes('\\') ? '\\' : '/') + parts.slice(-2).join(p.includes('\\') ? '\\' : '/')
}
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {

View File

@ -138,6 +138,34 @@ describe('project-rules API', () => {
expect(res.status).toBe(400)
})
it('POST /api/project-rules/create rejects path traversal in filename', async () => {
const url = new URL('http://localhost/api/project-rules/create')
const res = await handleProjectRulesApi(
new Request(url, {
method: 'POST',
body: JSON.stringify({ scope: 'project-root', cwd: MOCK_PROJECT, filename: '../../../escaped.md' }),
}),
url,
['api', 'project-rules', 'create'],
)
expect(res.status).toBe(400)
const data = await res.json() as { error: string }
expect(data.error).toBe('Invalid filename')
})
it('POST /api/project-rules/create rejects absolute filename', async () => {
const url = new URL('http://localhost/api/project-rules/create')
const res = await handleProjectRulesApi(
new Request(url, {
method: 'POST',
body: JSON.stringify({ scope: 'project-rules', cwd: MOCK_PROJECT, filename: path.join(MOCK_CLAUDE_HOME, 'evil.md') }),
}),
url,
['api', 'project-rules', 'create'],
)
expect(res.status).toBe(400)
})
it('POST /api/project-rules/create does not overwrite existing file', async () => {
const userPath = path.join(MOCK_CLAUDE_HOME, 'CLAUDE.md')
mockFiles.add(userPath)

View File

@ -4,17 +4,23 @@
* GET /api/project-rules List all known projects and their CLAUDE.md files
* POST /api/project-rules/create Create a CLAUDE.md file if it doesn't exist
*
* Scans ~/.claude/projects/ for all known project directories (same as memory),
* then checks each project for CLAUDE.md files at all valid locations:
* Project discovery mirrors the memory API: scan ~/.claude/projects/ for known
* project IDs, then resolve each ID back to a real filesystem path by reading
* session (.jsonl) file heads for a `cwd` field, falling back to a sanitized
* directory search. For each resolved project we check all CLAUDE.md locations
* the harness actually loads:
* - Project root: CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md, CLAUDE.local.md
* - User level: ~/.claude/CLAUDE.md, ~/.claude/rules/*.md
* Plus user-level rules shared across all projects:
* - ~/.claude/CLAUDE.md, ~/.claude/rules/*.md
*/
import * as path from 'path'
import * as fs from 'fs/promises'
import { homedir } from 'os'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getCwd } from '../../utils/cwd.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { sanitizePath, containsPathTraversal } from '../../utils/path.js'
type RuleFile = {
path: string
@ -31,6 +37,12 @@ type ProjectRulesEntry = {
files: RuleFile[]
}
// Bounds for resolving a project ID back to a filesystem path.
const SESSION_SCAN_LIMIT = 5
const HEAD_BYTES = 4096
const FS_SEARCH_DEPTH = 8
const FS_SEARCH_NODE_LIMIT = 4000
export async function handleProjectRulesApi(
req: Request,
url: URL,
@ -56,92 +68,93 @@ function getProjectsDir(): string {
return path.join(getClaudeConfigHomeDir(), 'projects')
}
function sanitizePath(p: string): string {
return p.replace(/[<>:"/\\|?*]/g, '-').replace(/^\.+/, '_')
}
async function getProjectRules(url: URL): Promise<Response> {
const cwd = url.searchParams.get('cwd') || getCwd()
const claudeHome = getClaudeConfigHomeDir()
const projectsDir = getProjectsDir()
// Resolve current project ID
const currentProjectPath = findCanonicalGitRoot(cwd) ?? cwd
// Current project: prefer git root, fall back to cwd.
const currentProjectPath = (findCanonicalGitRoot(cwd) ?? cwd).normalize('NFC')
const currentProjectId = sanitizePath(currentProjectPath)
// Scan all known project directories
const projectMap = new Map<string, { id: string; isCurrent: boolean }>()
projectMap.set(currentProjectId, { id: currentProjectId, isCurrent: true })
// Collect project IDs: current + every dir under ~/.claude/projects/
const projectIds = new Map<string, boolean>() // id -> isCurrent
projectIds.set(currentProjectId, true)
try {
const entries = await fs.readdir(projectsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
if (!projectMap.has(entry.name)) {
projectMap.set(entry.name, { id: entry.name, isCurrent: false })
}
if (!isValidProjectId(entry.name)) continue
if (!projectIds.has(entry.name)) projectIds.set(entry.name, false)
}
} catch {
// projects dir may not exist
// projects dir may not exist yet
}
// For each project, try to resolve its real path and scan rules
const projects: ProjectRulesEntry[] = []
for (const [, { id, isCurrent }] of projectMap) {
const projectPath = isCurrent ? currentProjectPath : await inferProjectPath(id)
const files: RuleFile[] = []
if (projectPath) {
// Root CLAUDE.md
const rootMd = path.join(projectPath, 'CLAUDE.md')
files.push({ path: rootMd, exists: await fileExists(rootMd), type: 'project', label: 'CLAUDE.md' })
// .claude/CLAUDE.md
const dotClaudeMd = path.join(projectPath, '.claude', 'CLAUDE.md')
files.push({ path: dotClaudeMd, exists: await fileExists(dotClaudeMd), type: 'project', label: '.claude/CLAUDE.md' })
// .claude/rules/*.md
const rulesDir = path.join(projectPath, '.claude', 'rules')
for (const rulePath of await listMdFiles(rulesDir)) {
files.push({ path: rulePath, exists: true, type: 'project', label: '.claude/rules/' + path.basename(rulePath) })
const projects: ProjectRulesEntry[] = await Promise.all(
Array.from(projectIds.entries()).map(async ([id, isCurrent]) => {
const projectPath = isCurrent
? currentProjectPath
: await resolveProjectPath(id)
const files = projectPath ? await scanProjectFiles(projectPath) : []
return {
id,
label: projectPath ?? unsanitizeProjectLabel(id),
projectPath,
isCurrent,
files,
}
// CLAUDE.local.md
const localMd = path.join(projectPath, 'CLAUDE.local.md')
files.push({ path: localMd, exists: await fileExists(localMd), type: 'local', label: 'CLAUDE.local.md' })
}
projects.push({
id,
label: projectPath ?? unsanitize(id),
projectPath,
isCurrent,
files,
})
}
}),
)
// User-level rules (shared across all projects)
const userFiles: RuleFile[] = []
const userMd = path.join(claudeHome, 'CLAUDE.md')
userFiles.push({ path: userMd, exists: await fileExists(userMd), type: 'user', label: '~/.claude/CLAUDE.md' })
const userRulesDir = path.join(claudeHome, 'rules')
for (const rulePath of await listMdFiles(userRulesDir)) {
for (const rulePath of await listMdFiles(path.join(claudeHome, 'rules'))) {
userFiles.push({ path: rulePath, exists: true, type: 'user', label: '~/.claude/rules/' + path.basename(rulePath) })
}
// Sort: current project first, then alphabetical
// Sort: current first, then projects with existing files, then alphabetical.
projects.sort((a, b) => {
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
const aHas = a.files.some(f => f.exists)
const bHas = b.files.some(f => f.exists)
if (aHas !== bHas) return aHas ? -1 : 1
return a.label.localeCompare(b.label)
})
// Filter out projects with no existing files and no resolvable path (unless current)
const filtered = projects.filter(p => p.isCurrent || p.projectPath !== null)
// Keep: current project, or any project that resolved to a real path AND has
// at least one existing rules file. This avoids listing dozens of stale
// project entries with no rules.
const filtered = projects.filter(
p => p.isCurrent || (p.projectPath !== null && p.files.some(f => f.exists)),
)
return Response.json({ projects: filtered, userFiles, cwd })
}
/** Scan a project directory for all CLAUDE.md rule file locations. */
async function scanProjectFiles(projectPath: string): Promise<RuleFile[]> {
const files: RuleFile[] = []
const rootMd = path.join(projectPath, 'CLAUDE.md')
files.push({ path: rootMd, exists: await fileExists(rootMd), type: 'project', label: 'CLAUDE.md' })
const dotClaudeMd = path.join(projectPath, '.claude', 'CLAUDE.md')
files.push({ path: dotClaudeMd, exists: await fileExists(dotClaudeMd), type: 'project', label: '.claude/CLAUDE.md' })
for (const rulePath of await listMdFiles(path.join(projectPath, '.claude', 'rules'))) {
files.push({ path: rulePath, exists: true, type: 'project', label: '.claude/rules/' + path.basename(rulePath) })
}
const localMd = path.join(projectPath, 'CLAUDE.local.md')
files.push({ path: localMd, exists: await fileExists(localMd), type: 'local', label: 'CLAUDE.local.md' })
return files
}
async function createProjectRulesFile(req: Request, url: URL): Promise<Response> {
let body: { scope?: string; cwd?: string; filename?: string }
try {
@ -154,6 +167,14 @@ async function createProjectRulesFile(req: Request, url: URL): Promise<Response>
const cwd = body.cwd || url.searchParams.get('cwd') || getCwd()
const filename = body.filename || 'CLAUDE.md'
// Security: filename is user-controlled and flows into path.join below.
// Reject traversal ("../") and absolute paths so a crafted filename cannot
// write outside the intended scope directory. The harness only ever sends
// basenames, but this endpoint is unauthenticated server input.
if (containsPathTraversal(filename) || path.isAbsolute(filename) || filename.includes('\0')) {
return Response.json({ error: 'Invalid filename' }, { status: 400 })
}
let filePath: string
if (scope === 'project-root') {
filePath = path.join(cwd, filename)
@ -175,16 +196,160 @@ async function createProjectRulesFile(req: Request, url: URL): Promise<Response>
return Response.json({ ok: true, path: filePath, created: false })
}
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
const template = getTemplate(scope, filename)
await fs.writeFile(filePath, template, 'utf-8')
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, getTemplate(scope, filename), 'utf-8')
return Response.json({ ok: true, path: filePath, created: true })
}
// --- Helpers ---
// --- Project ID → filesystem path resolution (mirrors memory.ts) ---
/** Resolve a sanitized project ID back to a real directory path. */
async function resolveProjectPath(projectId: string): Promise<string | null> {
const fromSession = await inferProjectPathFromSessionFiles(projectId)
if (fromSession) return fromSession
const fromFs = await inferProjectPathFromExistingDirectory(projectId)
return fromFs ?? null
}
async function inferProjectPathFromSessionFiles(projectId: string): Promise<string | undefined> {
const projectDir = path.join(getProjectsDir(), projectId)
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(projectDir, { withFileTypes: true })
} catch {
return undefined
}
const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = []
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
const filePath = path.join(projectDir, entry.name)
try {
const stat = await fs.stat(filePath)
sessionFiles.push({ filePath, mtimeMs: stat.mtimeMs })
} catch {
// racing delete — skip
}
}
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs)
for (const { filePath } of sessionFiles.slice(0, SESSION_SCAN_LIMIT)) {
const head = await readFileHead(filePath, HEAD_BYTES)
const candidate =
extractJsonStringField(head, 'cwd') ??
extractJsonStringField(head, 'workDir') ??
extractJsonStringField(head, 'projectPath')
if (candidate && path.isAbsolute(candidate)) return candidate.normalize('NFC')
}
return undefined
}
async function readFileHead(filePath: string, bytes: number): Promise<string> {
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
try {
handle = await fs.open(filePath, 'r')
const buffer = Buffer.alloc(bytes)
const { bytesRead } = await handle.read(buffer, 0, bytes, 0)
return buffer.subarray(0, bytesRead).toString('utf-8')
} catch {
return ''
} finally {
await handle?.close()
}
}
function extractJsonStringField(head: string, field: string): string | undefined {
// Cheap extraction without full JSON parse — head may be truncated mid-line.
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`)
const m = head.match(re)
if (!m) return undefined
try {
return JSON.parse(`"${m[1]}"`)
} catch {
return undefined
}
}
async function inferProjectPathFromExistingDirectory(projectId: string): Promise<string | undefined> {
const roots = Array.from(new Set([
homedir(),
process.env.HOME,
process.env.USERPROFILE,
'/private/tmp',
'/tmp',
].filter((root): root is string => Boolean(root && path.isAbsolute(root)))))
for (const root of roots) {
const resolvedRoot = path.resolve(root)
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(resolvedRoot))) continue
const match = await findDirectoryBySanitizedPath(projectId, resolvedRoot, 0, { visited: 0 })
if (match) return match.normalize('NFC')
}
return undefined
}
async function findDirectoryBySanitizedPath(
projectId: string,
candidate: string,
depth: number,
state: { visited: number },
): Promise<string | undefined> {
if (state.visited >= FS_SEARCH_NODE_LIMIT) return undefined
state.visited += 1
const candidateId = sanitizePath(candidate)
if (candidateId === projectId) return candidate
if (depth >= FS_SEARCH_DEPTH || !sanitizedPrefixCanMatch(projectId, candidateId)) {
return undefined
}
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(candidate, { withFileTypes: true })
} catch {
return undefined
}
entries.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const child = path.join(candidate, entry.name)
if (!sanitizedPrefixCanMatch(projectId, sanitizePath(child))) continue
if (entry.isSymbolicLink() && !(await directoryExists(child))) continue
const match = await findDirectoryBySanitizedPath(projectId, child, depth + 1, state)
if (match) return match
}
return undefined
}
function sanitizedPrefixCanMatch(projectId: string, prefix: string): boolean {
if (projectId === prefix) return true
return prefix.endsWith('-')
? projectId.startsWith(prefix)
: projectId.startsWith(`${prefix}-`)
}
// --- Misc helpers ---
function isValidProjectId(projectId: string): boolean {
return (
projectId.length > 0 &&
!projectId.includes('\0') &&
!projectId.includes('/') &&
!projectId.includes('\\') &&
projectId !== '.' &&
projectId !== '..'
)
}
function unsanitizeProjectLabel(projectId: string): string {
return projectId.replace(/^-/, '/').replace(/-/g, '/')
}
function getTemplate(scope: string, filename: string): string {
if (scope === 'local') {
@ -209,6 +374,15 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
async function directoryExists(dir: string): Promise<boolean> {
try {
const stat = await fs.stat(dir)
return stat.isDirectory()
} catch {
return false
}
}
async function listMdFiles(dirPath: string): Promise<string[]> {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
@ -219,51 +393,3 @@ async function listMdFiles(dirPath: string): Promise<string[]> {
return []
}
}
function unsanitize(id: string): string {
// Best-effort reverse of sanitizePath for display
return id.replace(/-/g, path.sep)
}
async function inferProjectPath(projectId: string): Promise<string | null> {
// Try to read a session file from the project dir to find the real path
const projectDir = path.join(getProjectsDir(), projectId)
try {
const entries = await fs.readdir(projectDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
// Read first few bytes to find workDir
const filePath = path.join(projectDir, entry.name)
const content = await fs.readFile(filePath, { encoding: 'utf-8' })
const firstLine = content.split('\n')[0]
try {
const obj = JSON.parse(firstLine)
if (obj.cwd && typeof obj.cwd === 'string') {
// Verify the directory still exists
try {
await fs.access(obj.cwd)
return obj.cwd
} catch {
// Directory no longer exists
}
}
} catch {
// Not valid JSON
}
break // Only check first session file
}
} catch {
// Directory not readable
}
// Fallback: check if unsanitized path exists as a directory
const guessedPath = unsanitize(projectId)
try {
const stat = await fs.stat(guessedPath)
if (stat.isDirectory()) return guessedPath
} catch {
// nope
}
return null
}