Preserve instruction symlink guard through fs seam
This commit is contained in:
@@ -4,7 +4,7 @@ Project instruction file loader for the harness. It discovers the configured per
|
||||
|
||||
## Behavior
|
||||
|
||||
The plugin listens on the `agent/request` waterfall and reads instruction file content through the `ctx.fs` provider seam. It deliberately does not declare `fs` as a static dependency: `agent-core` can load the plugin in providerless app trees, and the plugin simply does nothing until a filesystem provider is present at request/tool time. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory by checking `instructionFileCandidates` in order. With the default candidates, `AGENTS.md` wins and `CLAUDE.md` is a compatibility fallback.
|
||||
The plugin listens on the `agent/request` waterfall and reads instruction file content through the `ctx.fs` provider seam. It uses `ctx.fs.lstat` before `ctx.fs.resolve` so repository-owned instruction symlinks are skipped rather than followed across trust boundaries. It deliberately does not declare `fs` as a static dependency: `agent-core` can load the plugin in providerless app trees, and the plugin simply does nothing until a filesystem provider is present at request/tool time. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory by checking `instructionFileCandidates` in order. With the default candidates, `AGENTS.md` wins and `CLAUDE.md` is a compatibility fallback.
|
||||
|
||||
The plugin also listens on `tools/post-execute` for successful structured filesystem touches from the first-party `read`, `write`, and `edit` tools. When one of those tools touches a descendant of the session cwd, the plugin checks the directories between the session cwd and the touched file for instruction files that are not already visible in session context, then attaches them as `additionalContext` so the loop records a durable `context/message` for the next model request. This intentionally follows file-tool touches, not shell `cd`: `dsh-bash-local` uses fresh shells per call, and parsing arbitrary shell commands for reached paths would be brittle.
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
|
||||
|
||||
async function fsStatFile(path: string, fileSystem: FileSystem): Promise<DiscoveredInstructionFile['signature'] & { target: FsTarget } | undefined> {
|
||||
try {
|
||||
const pathInfo = await fileSystem.lstat(path)
|
||||
if (pathInfo?.type !== 'file') return undefined
|
||||
const target = await fileSystem.resolve(path)
|
||||
const info = await fileSystem.stat(target)
|
||||
if (info?.type !== 'file') return undefined
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user