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.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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\<user>\`) 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user