Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user>: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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/elevation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +115,7 @@ async function getElevationSigningKey(): Promise<Buffer> {
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();
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/mcp-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -62,6 +63,7 @@ async function writeTokenStore(store: TokenStore): Promise<void> {
// 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
Expand All @@ -72,6 +74,7 @@ async function writeTokenStore(store: TokenStore): Promise<void> {
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(() => {});
Expand Down
10 changes: 7 additions & 3 deletions src/memory/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = `
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions src/utils/secure-perms.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions src/utils/secure-perms.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading