From 27831e3cc0b2d2dac11b938765fc6d0a80b32e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Wed, 24 Jun 2026 20:44:13 +0200 Subject: [PATCH 1/2] feat(win): icacls-based owner-only ACLs for secret files (#43-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSIX mode bits (0o600/0o700) are no-ops on NTFS — secret files were unprotected on native Windows. New cross-platform secure-perms helper: chmod on POSIX, 'icacls /inheritance:r /grant:r :F' on Windows (strip inheritance, grant only the current user). Wired into the secret stores: memory.db + dir, mcp-tokens.json + dir, elevation.key, totp-secret. POSIX behavior byte-identical (63 perm tests still green on macOS); Windows branch verified via the probe. Co-Authored-By: Claude Opus 4.8 --- src/elevation.ts | 3 ++ src/mcp-orchestrator.ts | 3 ++ src/memory/db.ts | 10 +++++-- src/utils/secure-perms.test.ts | 28 +++++++++++++++++++ src/utils/secure-perms.ts | 51 ++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 src/utils/secure-perms.test.ts create mode 100644 src/utils/secure-perms.ts diff --git a/src/elevation.ts b/src/elevation.ts index 43dc9b74..b3200963 100644 --- a/src/elevation.ts +++ b/src/elevation.ts @@ -19,6 +19,7 @@ import { readFile, writeFile, mkdir, access } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { appendAuditEventDirect } from "./audit.js"; +import { secureFile } from "./utils/secure-perms.js"; const ELEVATION_FILE = ".kit/elevation.json"; const DEFAULT_TTL_MINUTES = 15; @@ -114,6 +115,7 @@ async function getElevationSigningKey(): Promise { mode: 0o600, flag: "wx", }); + secureFile(keyPath); // owner-only on Windows (NTFS ignores mode) — #43 return key; } catch { const hex = (await readFile(keyPath, "utf-8")).trim(); @@ -329,6 +331,7 @@ export async function enrollTotp(opts: { await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, secret + "\n", { encoding: "utf-8", mode: 0o600 }); + secureFile(filePath); // owner-only on Windows (NTFS ignores mode) — #43 return { secret, diff --git a/src/mcp-orchestrator.ts b/src/mcp-orchestrator.ts index 0bba098d..e589435d 100644 --- a/src/mcp-orchestrator.ts +++ b/src/mcp-orchestrator.ts @@ -28,6 +28,7 @@ import { readFile, writeFile, mkdir, rename, access, unlink } from "node:fs/prom import { homedir } from "node:os"; import { dirname } from "node:path"; import type { McpConfig, McpServerConfig } from "./config.js"; +import { secureFile, secureDir } from "./utils/secure-perms.js"; const TOKEN_FILE = `${homedir()}/.kit/mcp-tokens.json`; @@ -62,6 +63,7 @@ async function writeTokenStore(store: TokenStore): Promise { // owner-only too — defense in depth for the token store. const dir = dirname(TOKEN_FILE); await mkdir(dir, { recursive: true, mode: 0o700 }); + secureDir(dir); // Windows: NTFS ignores mode bits — enforce owner-only via ACL (#43) const tmp = `${TOKEN_FILE}.${process.pid}.tmp`; try { // wx flag = fail if exists (no clobber); mode passed to open() so the @@ -72,6 +74,7 @@ async function writeTokenStore(store: TokenStore): Promise { flag: "wx", }); await rename(tmp, TOKEN_FILE); + secureFile(TOKEN_FILE); // owner-only on Windows too (#43) } catch (err) { // Cleanup tmp if rename failed. await unlink(tmp).catch(() => {}); diff --git a/src/memory/db.ts b/src/memory/db.ts index c6eaccff..11ae3d6c 100644 --- a/src/memory/db.ts +++ b/src/memory/db.ts @@ -15,10 +15,11 @@ import { DatabaseSync } from "node:sqlite"; import { homedir } from "node:os"; import { join } from "node:path"; -import { existsSync, mkdirSync, chmodSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, statSync } from "node:fs"; import type { MemoryStats, MessageInput, SearchHit, SessionInput, ToolUseInput } from "./types.js"; import { summarizeTokens } from "./stats.js"; import { redactSecrets } from "../utils/redactSecrets.js"; +import { secureFile, secureDir } from "../utils/secure-perms.js"; export const SCHEMA_VERSION = 4; @@ -48,7 +49,10 @@ export function getMemoryDbPath(): string { function ensureMemoryDir(): void { const dir = getMemoryDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + secureDir(dir); // enforce owner-only on Windows too (NTFS ignores mode bits) — #43 + } } const SCHEMA_SQL = ` @@ -202,7 +206,7 @@ export function openMemoryDb(path?: string): DatabaseSync { migrate(db); if (dbPath !== ":memory:" && existsSync(dbPath)) { try { - chmodSync(dbPath, 0o600); + secureFile(dbPath); // 0o600 on POSIX, icacls owner-only on Windows — #43 } catch { // best-effort: non-POSIX filesystems may not support chmod } diff --git a/src/utils/secure-perms.test.ts b/src/utils/secure-perms.test.ts new file mode 100644 index 00000000..0c412d70 --- /dev/null +++ b/src/utils/secure-perms.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, statSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { secureFile, secureDir } from "./secure-perms.js"; + +// POSIX behavior is directly assertable (mode bits). The Windows icacls branch is +// exercised by the windows-latest probe (#43) — it's a no-op-on-POSIX here. +const posix = process.platform !== "win32"; + +describe("secure-perms (POSIX mode bits)", { skip: !posix }, () => { + it("secureFile restricts a file to 0o600", () => { + const dir = mkdtempSync(join(tmpdir(), "kit-sec-")); + const f = join(dir, "secret"); + writeFileSync(f, "x", { mode: 0o644 }); + secureFile(f); + assert.equal(statSync(f).mode & 0o777, 0o600); + }); + + it("secureDir restricts a dir to 0o700", () => { + const dir = mkdtempSync(join(tmpdir(), "kit-sec-")); + const sub = join(dir, "store"); + mkdirSync(sub, { mode: 0o755 }); + secureDir(sub); + assert.equal(statSync(sub).mode & 0o777, 0o700); + }); +}); diff --git a/src/utils/secure-perms.ts b/src/utils/secure-perms.ts new file mode 100644 index 00000000..fb839ab6 --- /dev/null +++ b/src/utils/secure-perms.ts @@ -0,0 +1,51 @@ +// Cross-platform "restrict to the current user" for secret files/dirs (#43). +// +// POSIX uses mode bits (0o600 / 0o700). On native Windows (NTFS) those bits are +// no-ops — `fs.chmod` doesn't restrict access — so a secret file written with +// `{ mode: 0o600 }` is still readable by other accounts. There we use `icacls`: +// strip inherited ACLs (`/inheritance:r`) and grant ONLY the current user, so the +// file/dir is genuinely owner-only. Best-effort + fail-soft: a missing icacls or +// unknown user never throws (the caller's write already happened). +import { chmodSync } from "node:fs"; +import { execFileSync } from "node:child_process"; + +function currentWindowsUser(): string | null { + // DOMAIN\\user is the most specific grant target; fall back to bare username. + const domain = process.env.USERDOMAIN; + const user = process.env.USERNAME; + if (!user) return null; + return domain ? `${domain}\\${user}` : user; +} + +/** Restrict a secret FILE to the current user (0o600 on POSIX; icacls on Windows). */ +export function secureFile(path: string): void { + if (process.platform !== "win32") { + chmodSync(path, 0o600); + return; + } + const user = currentWindowsUser(); + if (!user) return; + try { + execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${user}:F`], { stdio: "ignore" }); + } catch { + // best-effort — icacls absent / restricted shell + } +} + +/** Restrict a secret DIR to the current user (0o700 on POSIX; icacls (OI)(CI) on Windows). */ +export function secureDir(path: string): void { + if (process.platform !== "win32") { + chmodSync(path, 0o700); + return; + } + const user = currentWindowsUser(); + if (!user) return; + try { + // (OI)(CI) = object- + container-inherit, so files created later inherit owner-only. + execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { + stdio: "ignore", + }); + } catch { + // best-effort + } +} From cc513327ab458ac58181a5735a0d65f01766b667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Wed, 24 Jun 2026 20:48:46 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=201.30.0=20=E2=80=94=20Wind?= =?UTF-8?q?ows=20ACLs=20(icacls)=20for=20secret=20files=20(#43-B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 599052d1..5a21469c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [1.30.0] - 2026-06-24 + +### Added + +- **Windows ACLs for secret files (#43).** POSIX mode bits (`0o600`/`0o700`) are no-ops on NTFS, so kit's secret stores were unprotected on native Windows. New cross-platform `secure-perms` helper: `chmod` on POSIX; on Windows, `icacls /inheritance:r /grant:r :F` (strip inherited ACLs, grant only the current user). Wired into the secret stores: `~/.kit/memory.db` (+ dir), `mcp-tokens.json` (+ dir), `elevation.key`, `totp-secret`. POSIX behavior is byte-identical (63 perm tests still pass on macOS; new helper unit-tested); the Windows branch is exercised by the `windows-latest` probe. Closes the perms half of #43; the remaining Windows test-suite gaps (build ✓, 1526/1542 pass) are mapped on #43. + ## [1.29.1] - 2026-06-24 ### Fixed diff --git a/package-lock.json b/package-lock.json index bb2b090b..c15177fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sandstream-kit", - "version": "1.29.1", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sandstream-kit", - "version": "1.29.1", + "version": "1.30.0", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 96f8cdad..b24fed63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sandstream-kit", - "version": "1.29.1", + "version": "1.30.0", "description": "developer kit. zero LLM, local-first, multi-vault. one command from git clone to working dev environment.", "license": "MIT", "funding": "https://buymeacoffee.com/sandstream",