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", 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 + } +}