Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
resolveProviderRetryAttempts,
} from "./constants.js";
import { isFileNotFoundError } from "./fs-errors.js";
import { restrictDirToCurrentUser } from "./windows-acl.js";

export const openWikiEnvDir = path.join(os.homedir(), ".openwiki");
export const openWikiEnvPath = path.join(openWikiEnvDir, ".env");
Expand Down Expand Up @@ -208,6 +209,7 @@ export async function saveOpenWikiEnv(updates: EnvMap): Promise<void> {
mode: 0o700,
});
await chmod(openWikiEnvDir, 0o700);
await restrictDirToCurrentUser(openWikiEnvDir);

await writeFile(openWikiEnvPath, formatEnv(nextEnv), {
encoding: "utf8",
Expand Down
2 changes: 2 additions & 0 deletions src/openwiki-home.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { chmod, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { restrictDirToCurrentUser } from "./windows-acl.js";

export const openWikiHomeDir = path.join(os.homedir(), ".openwiki");
export const openWikiConnectorsDir = path.join(openWikiHomeDir, "connectors");
Expand Down Expand Up @@ -30,6 +31,7 @@ export function getConnectorLogsDir(connectorId: string): string {
export async function ensureOpenWikiHome(): Promise<void> {
await mkdir(openWikiHomeDir, { recursive: true, mode: 0o700 });
await chmodIfExists(openWikiHomeDir, 0o700);
await restrictDirToCurrentUser(openWikiHomeDir);
await mkdir(openWikiConnectorsDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiLocalWikiDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiSkillsDir, { recursive: true, mode: 0o700 });
Expand Down
41 changes: 41 additions & 0 deletions src/windows-acl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { execFile } from "node:child_process";
import os from "node:os";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// Well-known SID for NT AUTHORITY\SYSTEM; the * prefix tells icacls it is a
// SID rather than an account name, so it resolves on any display language.
const SYSTEM_SID = "*S-1-5-18";

/**
* Mirrors the POSIX 0o700 owner-only intent on Windows, where fs.chmod only
* toggles the read-only attribute and leaves ACLs untouched: grants full
* control to the current user and SYSTEM (inheritable, so new children are
* covered), then removes inherited ACEs. The grant runs before the
* inheritance reset so a failed grant can never lock the user out of the
* directory. Best-effort by design: returns false instead of throwing so
* ACL tooling problems never block a run. No-op on non-Windows platforms.
*/
export async function restrictDirToCurrentUser(
dirPath: string,
): Promise<boolean> {
if (process.platform !== "win32") {
return false;
}

const userName = os.userInfo().username;

try {
await execFileAsync("icacls", [
dirPath,
"/grant:r",
`${userName}:(OI)(CI)F`,
`${SYSTEM_SID}:(OI)(CI)F`,
]);
await execFileAsync("icacls", [dirPath, "/inheritance:r"]);
return true;
} catch {
return false;
}
}
97 changes: 97 additions & 0 deletions test/windows-acl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import os from "node:os";
import { afterEach, describe, expect, test, vi } from "vitest";

const execFileMock = vi.hoisted(() =>
vi.fn(
(
_command: string,
_args: string[],
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "");
},
),
);

vi.mock("node:child_process", () => ({
execFile: execFileMock,
}));

import { restrictDirToCurrentUser } from "../src/windows-acl.ts";

const realPlatform = process.platform;

function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}

afterEach(() => {
setPlatform(realPlatform);
execFileMock.mockClear();
});

describe("restrictDirToCurrentUser", () => {
test("is a no-op on non-Windows platforms", async () => {
setPlatform("linux");

const restricted = await restrictDirToCurrentUser("/home/user/.openwiki");

expect(restricted).toBe(false);
expect(execFileMock).not.toHaveBeenCalled();
});

test("grants the current user and SYSTEM, then removes inheritance", async () => {
setPlatform("win32");

const restricted = await restrictDirToCurrentUser(
"C:\\Users\\u\\.openwiki",
);

expect(restricted).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(2);

const [grantCommand, grantArgs] = execFileMock.mock.calls[0] as unknown as [
string,
string[],
];
expect(grantCommand).toBe("icacls");
expect(grantArgs).toEqual([
"C:\\Users\\u\\.openwiki",
"/grant:r",
`${os.userInfo().username}:(OI)(CI)F`,
"*S-1-5-18:(OI)(CI)F",
]);

const [, inheritanceArgs] = execFileMock.mock.calls[1] as unknown as [
string,
string[],
];
expect(inheritanceArgs).toEqual([
"C:\\Users\\u\\.openwiki",
"/inheritance:r",
]);
});

test("does not remove inheritance when the grant fails, so a failed grant cannot lock the user out", async () => {
setPlatform("win32");
execFileMock.mockImplementationOnce(
(
_command: string,
_args: string[],
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("icacls failed"), "", "");
},
);

const restricted = await restrictDirToCurrentUser(
"C:\\Users\\u\\.openwiki",
);

expect(restricted).toBe(false);
expect(execFileMock).toHaveBeenCalledTimes(1);
});
});