mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +08:00
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:
parent
4c9a8f57ca
commit
2f594cf7e3
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': 'メモリ',
|
||||
|
||||
@ -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': '메모리',
|
||||
|
||||
@ -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': '記憶',
|
||||
|
||||
@ -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': '记忆',
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user