From e47ae0643c3797f8c759b06396783c8053b7dc5b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 21:15:46 +0800 Subject: [PATCH] fs-local: POSIX-only mode-bit assertions, document Windows DACL-inheritance semantics Windows drives only the read-only attribute through chmod and reports synthetic stat mode bits, so writeFileAtomic's mode arguments are inert there; write-in-progress privacy comes from the staging dir (created in the target's parent) inheriting the destination directory's DACL. Production is deliberately unchanged -- the chmod calls are benign no-ops and platform-guarding them out buys nothing. Tests guard the mode-bit expects to POSIX; there is no Windows ACL assertion because an ACL check would pin OS inheritance plus the machine's %TEMP% ACL, not this package. Decision and rejected alternatives (explicit DACLs, Get-Acl/icacls test verification) recorded in the new RFC. --- docs/rfc/INDEX.md | 1 + .../2026-07-05-windows-fs-permissions.md | 29 +++++++++++++++++++ packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/fsio.ts | 4 ++- packages/fs/fs-local/tests/fsio.spec.ts | 19 +++++++++--- 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 32f11b744a..5ebf52f07a 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -150,6 +150,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 | | [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 | | [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 | +| [Windows write-permission semantics — inherited DACLs, not mode bits](implemented/architecture/2026-07-05-windows-fs-permissions.md) | 2026-07-05 | | [Windows-native durable JSONL publication](implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md) | 2026-07-05 | | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | | [Tool result retention library](implemented/architecture/2026-07-06-tool-result-retention-library.md) | 2026-07-06 | diff --git a/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md b/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md new file mode 100644 index 0000000000..0001e8223c --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md @@ -0,0 +1,29 @@ +# RFC: Windows write-permission semantics — inherited DACLs, not mode bits + +Status: implemented + +## Problem + +`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions. + +Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL, which this code never sets; a newly created file or directory inherits its DACL from its parent directory. + +## Decision + +Production code is unchanged: no platform fork, no DACL management. The Windows privacy invariant is structural rather than mode-driven — the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit exactly the destination directory's DACL, and write-in-progress content is never exposed more widely than the destination itself. In the typical deployment (a coding agent writing the user's own project tree under `C:\Users\\`) the inherited DACL is owner + SYSTEM + Administrators, matching the POSIX intent. + +Tests assert mode bits on POSIX only. There is no Windows-side ACL assertion because there is no Windows-side code behavior to pin: an ACL check on a `mkdtemp(tmpdir())` fixture would verify Windows DACL inheritance plus the machine's `%TEMP%` ACL — the operating system, not this package — and no change to this package could turn it red. + +## Alternatives considered + +**Explicit protected DACLs.** Granting owner-only access would require per-write FFI or a subprocess, break inheritance, and surprise users whose project directories are deliberately shared. This becomes appropriate only if the threat model includes hostile local readers of broadly accessible target directories. + +**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile. + +**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior. + +## Consequences + +POSIX keeps the stronger guarantee: owner-only temp content regardless of the parent directory. Windows guarantees only "no wider than the destination": a target inside a broadly accessible directory (a share, a permissive `D:\` root) gets equally accessible write-in-progress content. The gap is deliberate and documented, not an oversight. + +Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced at all there — `rename` over it fails before the preserved mode would matter. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 65ed76efce..7c3899e5cb 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows the mode bits drive only the read-only attribute, and write-in-progress privacy comes instead from the staging dir inheriting the destination directory's DACL ([Windows write-permission RFC](../../../docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index e0745f1735..4603611ce4 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -408,9 +408,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow /** * Atomically replace a file through a private, synced staging file in the same directory. + * POSIX protects the staging directory and file with `0o700` and `0o600`; Windows + * inherits the destination directory's DACL because Node mode bits are synthetic there. * @param absolutePath - destination; missing parent directories are created. * @param content - the full UTF-8 text to write. - * @param mode - final mode, or `0o600` when omitted. + * @param mode - final POSIX mode, or `0o600` when omitted; inert on Windows. * @param signal - cancellation checked before the final rename. * @param internals - test seam for pinning temp names and observing the staged file. */ diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 199c01f411..559ef86563 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -367,6 +367,12 @@ describe('streamWholeText', () => { }) }) +// Windows drives only the read-only attribute through `chmod` and reports +// synthetic `stat` mode bits, so mode assertions are POSIX-only; on Windows +// write-in-progress privacy comes from the destination directory's inherited +// DACL (docs/rfc/implemented/architecture/2026-07-05-windows-fs-permissions.md). +const posixModes = process.platform !== 'win32' + describe('writeFileAtomic — temp-file safety', () => { it('writes through a private staging dir and owner-only temp file', async () => { const file = join(dir, 'a.txt') @@ -374,17 +380,22 @@ describe('writeFileAtomic — temp-file safety', () => { await writeFileAtomic(file, 'hello', 0o640, undefined, { inspectTemp: async ({ stagingDir, tempPath }) => { inspected = true - expect((await stat(stagingDir)).mode & 0o777).toBe(0o700) - expect((await stat(tempPath)).mode & 0o777).toBe(0o600) + const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)]) + expect(staging.isDirectory()).toBe(true) + expect(temp.isFile()).toBe(true) + if (posixModes) { + expect(staging.mode & 0o777).toBe(0o700) + expect(temp.mode & 0o777).toBe(0o600) + } }, }) expect(inspected).toBe(true) expect(await readFile(file, 'utf8')).toBe('hello') - expect((await stat(file)).mode & 0o777).toBe(0o640) + if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640) expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) - it('creates new files owner-only by default', async () => { + it.skipIf(!posixModes)('creates new files owner-only by default', async () => { const file = join(dir, 'a.txt') await writeFileAtomic(file, 'hello', undefined, undefined) expect((await stat(file)).mode & 0o777).toBe(0o600)