diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d10a7eeabd..c48756e50e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -159,7 +159,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:125`](../../packages/fs/fs/src/index.ts) ### `fs/observed` — emit @@ -171,7 +171,7 @@ Record that an actor observed a target at a version, after a successful read/wri Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:140`](../../packages/fs/fs/src/index.ts) ### `fs/write-intent` — waterfall @@ -183,7 +183,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:111`](../../packages/fs/fs/src/index.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c91f1578b2..84e133e197 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -87,12 +87,13 @@ Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/comp ## `ctx.fs` — `FileSystem` (abstract seam) -Abstract filesystem provider service. Subclass, implement the seven storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Abstract filesystem provider service. Subclass, implement the eight storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). Semantics every backend must honor: - resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks). - stat returns FsInfo metadata (never content) or `undefined` when the target is absent. +- lstat returns path metadata without following the final path component if it is a symlink. Consumers that treat repository-owned symlinks as a trust-boundary hazard use this BEFORE resolve; ordinary target operations still use `resolve` + `stat`. - readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`. - listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. - writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write. @@ -101,6 +102,7 @@ Semantics every backend must honor: ```ts cordis-catalog abstract resolve(path: string, opts?: { cwd?: string }): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise +abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> abstract listDir(target: FsTarget, signal?: AbortSignal): Promise @@ -110,7 +112,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:178`](../../packages/fs/fs/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index bbc55be966..dd7071268d 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -37,6 +37,16 @@ interface FsInfo { } ``` +`lstat` is the path-level no-follow metadata primitive. It takes a path instead of an `FsTarget` because `resolve` intentionally follows symlinks to produce stable identity; consumers that need trust-boundary checks can call `lstat` first and reject `symlink` before resolving. + +```ts type-equiv +interface FsPathInfo { + version: FsVersion + type: 'file' | 'directory' | 'symlink' | 'other' + size?: number +} +``` + `listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. ```ts type-equiv @@ -144,4 +154,4 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9374732fe7..3af74140ca 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,9 +18,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:125`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:140`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:111`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:33`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:36`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:44`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | diff --git a/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md b/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md index 07e94950a4..345d68f039 100644 --- a/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md +++ b/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md @@ -18,6 +18,8 @@ The plugin is loaded by `@deepseek-ai/dsh-agent-core` so both product front door The implementation ships baseline loading plus structured file-tool nested loading. The baseline path is the user-global instruction file plus the ancestor chain from project root to the session cwd. When the real `read`, `write`, or `edit` tools successfully touch a descendant path, the plugin loads newly discovered instruction files between the session cwd and the touched file. It deliberately does not add a generic `contextPaths()` hook or parse arbitrary shell commands; those would add broader path-reporting semantics than this feature needs. +Instruction file reads go through the optional `ctx.fs` provider seam. The plugin calls `ctx.fs.lstat` before `ctx.fs.resolve`, so repository-owned instruction symlinks are skipped rather than followed to another path. This preserves the safety property originally provided by host `lstat` checks while still allowing virtual/sandboxed providers to expose files that do not exist on the host filesystem. + ### File names and precedence The native file name is `AGENTS.md`. `CLAUDE.md` is a compatibility fallback, not a parallel default. The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`; in any one directory, the plugin loads at most one instruction file by checking that list in order. With defaults, `AGENTS.md` wins; if absent, `CLAUDE.md` may load. This mirrors opencode's conflict-avoidance policy rather than Reasonix's "load everything" policy, because a repo carrying both names is likely in transition and the two files can duplicate or contradict each other. @@ -124,6 +126,8 @@ Repository instructions are not necessarily trusted. The fenced workspace-contex Filesystem reads can fail between discovery and read. Missing/unreadable files should be skipped with debug logging, not fail the model turn. A disappearing file should not veto the model request. +Repository-controlled symlinks are a trust-boundary risk. Instruction discovery rejects path entries reported as symlinks by the filesystem provider rather than following them into arbitrary external files. + Multi-session isolation is load-bearing. Any implementation that stores the rendered block in a global system-prompt section is wrong for ACP and should be rejected in review. ## Deferred diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b1b9c25397..be5eab811b 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -20,7 +20,7 @@ import { randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' -import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' +import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' import type { Dirent, Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' @@ -111,6 +111,14 @@ export interface PathInfo { size: number } +/** Result of probing a path without following the final symlink component. */ +export interface PathLinkInfo { + version: FsVersion + mode: number + type: 'file' | 'directory' | 'symlink' | 'other' + size: number +} + /** One local directory child with a resolved target and cheap metadata. */ export interface LocalDirEntry { name: string @@ -165,21 +173,44 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { +function pathType(info: Stats): PathInfo['type'] { + if (info.isFile()) return 'file' + if (info.isDirectory()) return 'directory' + return 'other' +} + +function pathLinkType(info: Stats): PathLinkInfo['type'] { + if (info.isSymbolicLink()) return 'symlink' + return pathType(info) +} + +async function probeStats(absolutePath: string, readStats: (path: string) => Promise): Promise { try { - const info = await stat(absolutePath) - const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' - return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } + return await readStats(absolutePath) } catch (error: unknown) { // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean - // the target is absent; any other stat failure is a real permission/IO fault. - /* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */ + // the target is absent; any other metadata failure is a real permission/IO + // fault. + /* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */ if (!isENOENT(error) && !isENOTDIR(error)) throw error return null } } +/** Probe a path for its version, mode, type, and size. Null if absent. */ +export async function probe(absolutePath: string): Promise { + const info = await probeStats(absolutePath, stat) + if (!info) return null + return { version: versionOf(info), mode: info.mode & 0o777, type: pathType(info), size: info.size } +} + +/** Probe a path without following the final symlink component. Null if absent. */ +export async function probeNoFollow(absolutePath: string): Promise { + const info = await probeStats(absolutePath, lstat) + if (!info) return null + return { version: versionOf(info), mode: info.mode & 0o777, type: pathLinkType(info), size: info.size } +} + // --- Directory listing --- function listingIoError(displayPath: string, error: unknown): FsError { diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 0ad8365d4a..8da40656a3 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,6 +1,6 @@ /** * Local-filesystem implementation of the `ctx.fs` provider seam. - * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven + * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the eight * text-storage primitives with the host filesystem via * {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses * `realpath`, so the stable `targetKey` is the real file identity (two input @@ -14,6 +14,7 @@ */ import { Context } from 'cordis' +import { resolve } from 'node:path' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -21,6 +22,7 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, @@ -30,6 +32,7 @@ import { listDirectory, normalizeLineEndings, probe, + probeNoFollow, readForEdit, readTextForDiff, readWholeText, @@ -44,6 +47,7 @@ export { applyLiteralEdit, listDirectory, probe, + probeNoFollow, readForEdit, readTextForDiff, readWholeText, @@ -52,7 +56,7 @@ export { streamWholeText, writeFileAtomic, } from './fsio.ts' -export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo, PathLinkInfo } from './fsio.ts' /** Configuration for the local filesystem backend. */ export interface Config { @@ -114,6 +118,14 @@ export class LocalFileSystem extends FileSystem { return { version: info.version, type: info.type, size: info.size } } + override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED') + if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') + const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path)) + if (!info) return undefined + return { version: info.version, type: info.type, size: info.size } + } + override async readText(target: FsTarget, signal?: AbortSignal): Promise { return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 997021d346..22dc7a708b 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -92,6 +92,29 @@ describe('stat', () => { }) }) +describe('lstat', () => { + it('reports path metadata without following the final symlink component', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + + expect((await fs.lstat('real.txt'))?.type).toBe('file') + expect((await fs.lstat('link.txt'))?.type).toBe('symlink') + expect(await fs.lstat('missing.txt')).toBeUndefined() + }) + + it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => { + const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-')) + try { + await writeFile(join(other, 'x.txt'), 'in other') + expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file') + await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + } finally { + await rm(other, { recursive: true, force: true }) + } + }) +}) + describe('readText / streamText', () => { it('reads whole-file text', async () => { await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 3a30f73ed2..396f76bdd4 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -14,6 +14,7 @@ import { applyLiteralEdit, listDirectory, probe, + probeNoFollow, readForEdit, readWholeText, resolveLocalTarget, @@ -146,6 +147,27 @@ describe('probe', () => { }) }) +describe('probeNoFollow', () => { + it('reports symlinks without following them', async () => { + const real = join(dir, 'real.txt') + const link = join(dir, 'link.txt') + await writeFile(real, 'hi') + await symlink(real, link) + + expect((await probeNoFollow(real))?.type).toBe('file') + const linkInfo = await probeNoFollow(link) + expect(linkInfo?.type).toBe('symlink') + expect(typeof linkInfo?.version).toBe('string') + expect(linkInfo?.size).toBeGreaterThan(0) + }) + + it('returns null for a missing path or a file-valued ancestor path segment', async () => { + expect(await probeNoFollow(join(dir, 'missing'))).toBeNull() + await writeFile(join(dir, 'afile'), 'i am a file') + expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull() + }) +}) + describe('listDirectory', () => { it('lists direct children in stable order without reading content', async () => { const root = join(dir, 'skills') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index ac2866802b..3c323776fa 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-fs -The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -15,12 +15,13 @@ A future sandboxed, virtual, or remote backend implements this interface and the ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements seven primitives. +A backend subclasses `FileSystem` and implements eight primitives. | Member | Semantics | |---|---| | `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | +| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | | `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | @@ -41,4 +42,4 @@ This package declares three events (see the generated [events catalog](../../../ ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 1e0ab03b85..a2c3100e3e 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -63,6 +63,7 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsVersion, FsWriteIntent, @@ -80,6 +81,7 @@ export type { FsDirEntry, FsErrorCode, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, @@ -140,7 +142,7 @@ declare module 'cordis' { } /** - * Abstract filesystem provider service. Subclass, implement the seven storage + * Abstract filesystem provider service. Subclass, implement the eight storage * primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one * implementation per context; loading a second throws, cordis' standard * duplicate-service behavior). @@ -151,6 +153,10 @@ declare module 'cordis' { * guards and target lookup agree across paths (e.g. through symlinks). * - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined` * when the target is absent. + * - {@link lstat} returns path metadata without following the final path + * component if it is a symlink. Consumers that treat repository-owned symlinks + * as a trust-boundary hazard use this BEFORE {@link resolve}; ordinary target + * operations still use `resolve` + `stat`. * - {@link readText}/{@link streamText} read the whole regular text file (the * stream for large files); both own regular-file checks, UTF-8 decoding, * binary/NUL rejection, and `FS_NOT_TEXT`. @@ -201,6 +207,22 @@ export abstract class FileSystem extends Service { */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise + /** + * Return path metadata without following the final path component when it is a + * symbolic link. This is intentionally path-shaped, not target-shaped: + * {@link resolve} follows symlinks to produce the stable identity used by + * normal reads/writes, while `lstat` lets a consumer reject the path itself + * before that follow happens. + * + * `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is + * absent. + * @param path - the path to inspect; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent path. + */ + abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise + /** * Read the whole regular text file as a single decoded string. * @param target - the resolved target to read. diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 9351a373db..2c36bde5a7 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -76,6 +76,21 @@ export interface FsInfo { size?: number } +/** + * Metadata about a path without following the final path component when it is a + * symbolic link. Unlike {@link FsInfo}, this path-level probe can report + * `symlink` so consumers with trust-boundary rules can reject repository-owned + * links before resolving a target. + */ +export interface FsPathInfo { + /** Opaque freshness token of the path entry right now. */ + version: FsVersion + /** Whether the path entry is a regular file, directory, symlink, or other. */ + type: 'file' | 'directory' | 'symlink' | 'other' + /** Byte size of the path entry, when the backend can report it. */ + size?: number +} + /** * One direct child returned by {@link FileSystem.listDir}. Listing returns * metadata and resolved targets only; it must not read file contents. diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 86ba782c96..19ee033cce 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -13,12 +13,13 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -/** A minimal in-memory fake implementing the seven provider primitives. */ +/** A minimal in-memory fake implementing the eight provider primitives. */ class FakeFileSystem extends FileSystem { files = new Map() @@ -30,6 +31,11 @@ class FakeFileSystem extends FileSystem { if (content === undefined) return undefined return { version: FsVersion('v1'), type: 'file', size: content.length } } + override async lstat(path: string): Promise { + const content = this.files.get(path) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } + } override async readText(target: FsTarget): Promise { const content = this.files.get(target.targetKey) if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') @@ -120,6 +126,15 @@ describe('FileSystem provider seam', () => { const fs = ctx.fs as FakeFileSystem expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined() }) + + it('lstat returns path metadata before resolving a target', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + fs.files.set('a.txt', 'hi') + expect(await fs.lstat('a.txt')).toEqual({ version: 'v1', type: 'file', size: 2 }) + expect(await fs.lstat('missing.txt')).toBeUndefined() + }) }) describe('branded id factories', () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 53f912cce2..7e357b4dd9 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -19,6 +19,7 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, @@ -48,6 +49,11 @@ class FakeFs extends FileSystem { if (content === undefined) return undefined return { version: FsVersion('v1'), type: 'file', size: content.length } } + override async lstat(path: string): Promise { + const content = this.files.get(`key:${path}`) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } + } override async readText(target: FsTarget): Promise { return this.files.get(target.targetKey) ?? '' } diff --git a/packages/prompt/project-instructions/README.md b/packages/prompt/project-instructions/README.md index 1355e2d61a..6b68e01fec 100644 --- a/packages/prompt/project-instructions/README.md +++ b/packages/prompt/project-instructions/README.md @@ -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. diff --git a/packages/prompt/project-instructions/src/index.ts b/packages/prompt/project-instructions/src/index.ts index ff6315130d..944a196460 100644 --- a/packages/prompt/project-instructions/src/index.ts +++ b/packages/prompt/project-instructions/src/index.ts @@ -154,6 +154,8 @@ async function nodeStatFile(path: string): Promise { async function fsStatFile(path: string, fileSystem: FileSystem): Promise { 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 diff --git a/packages/prompt/project-instructions/tests/project-instructions.spec.ts b/packages/prompt/project-instructions/tests/project-instructions.spec.ts index ced921576f..375aaf4e25 100644 --- a/packages/prompt/project-instructions/tests/project-instructions.spec.ts +++ b/packages/prompt/project-instructions/tests/project-instructions.spec.ts @@ -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 { class RecordingFileSystem extends FileSystem { entries = new Map() + lstatTypes = new Map() throwOnStat = new Set() readTargets: string[] = [] @@ -61,6 +63,19 @@ class RecordingFileSystem extends FileSystem { return info } + override async lstat(path: string, opts?: { cwd?: string }): Promise { + 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 { 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() diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b1c3163782..a220ff62b8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -53,6 +53,7 @@ { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPathInfo", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },