Preserve instruction symlink guard through fs seam

This commit is contained in:
Yichen Jiang
2026-07-06 13:55:20 +08:00
parent 3c42352310
commit 901315d8ab
18 changed files with 258 additions and 25 deletions

View File

@@ -15,6 +15,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -41,6 +42,7 @@ async function write(path: string, content: string): Promise<void> {
class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string }>()
lstatTypes = new Map<string, FsPathInfo['type']>()
throwOnStat = new Set<string>()
readTargets: string[] = []
@@ -61,6 +63,19 @@ class RecordingFileSystem extends FileSystem {
return info
}
override async lstat(path: string, opts?: { cwd?: string }): Promise<FsPathInfo | undefined> {
const target = await this.resolve(path, opts)
const lstatType = this.lstatTypes.get(target.targetKey)
if (lstatType !== undefined) return { version: FsVersion(`lstat:${target.targetKey}`), type: lstatType }
const info = await this.stat(target)
if (info === undefined) return undefined
return {
version: info.version,
type: info.type,
...(info.size !== undefined ? { size: info.size } : {}),
}
}
override async readText(target: FsTarget): Promise<string> {
this.readTargets.push(target.targetKey)
return this.entries.get(target.targetKey)?.content ?? ''
@@ -250,6 +265,31 @@ describe('project instruction discovery', () => {
}
})
it('rejects symlinked instruction files through ctx.fs instead of following repository-controlled links', async () => {
const root = await tempRepo()
const home = await tempRepo()
const outside = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(outside, 'secret.txt'), 'outside secret')
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
const ctx = new Context()
await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
await rm(outside, { recursive: true, force: true })
}
})
it('disables baseline loading when the byte budget is zero', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -772,6 +812,33 @@ describe('project instruction request injection', () => {
}
})
it('skips provider-visible instruction candidates when ctx.fs stat disagrees after no-follow preflight', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads instruction files when ctx.fs omits the metadata size', async () => {
const root = await tempRepo()
const home = await tempRepo()