fix(browse): NTFS ACL hardening for Windows state files via icacls

gstack's ~/.gstack/ state directory holds bearer tokens, canary tokens, agent
queue contents (with prompt history), session state, security-decision logs,
and saved cookie bundles — all written with { mode: 0o600 } / 0o700. On Windows,
those mode bits are a silent no-op: Node's fs module doesn't translate POSIX
modes to NTFS ACLs, and inherited ACLs leave every "restricted" file readable
by other principals on the machine (verified via icacls — six ACEs, the
intended user is the LAST of six).

Threat model is non-trivial on:
  - Self-hosted CI runners (different service account on the same Windows box
    can read developer tokens, canary tokens, prompt history)
  - Shared development machines (agencies, studios, lab environments)
  - Multi-tenant servers with shared home directories

Orthogonal to v1.24.0.0's binary-resolution work — complementary at the write
side. v1.24's bin/gstack-paths resolves ~/.gstack/ correctly across plugin /
global / local installs; this PR ensures files written into those resolved
paths actually get the POSIX 0o600 semantic translated to NTFS.

The fix:
  - New browse/src/file-permissions.ts (158 LOC, 5 public + 1 test-reset).
    restrictFilePermissions / restrictDirectoryPermissions wrap chmod (POSIX)
    or icacls /inheritance:r /grant:r <user>:(F) (Windows). writeSecureFile /
    appendSecureFile / mkdirSecure are drop-in wrappers for the common patterns.
  - 19 call sites converted across 9 source files: browser-manager.ts,
    browser-skill-write.ts, cli.ts, config.ts, meta-commands.ts,
    security-classifier.ts, security.ts (4 sites), server.ts (5 sites),
    terminal-agent.ts (8 sites), tunnel-denial-log.ts.
  - (OI)(CI) inheritance flags on directories mean files created via fs.write*
    *inside* an mkdirSecure-created dir inherit the owner-only ACL automatically
    — important for tunnel-denial-log.ts where appends use async fsp.appendFile.

Error handling: icacls failures (nonexistent path, missing icacls.exe, hardened
environments) log a one-shot warning to stderr and proceed. Once-per-process
gating prevents log spam if the condition persists. Filesystem stays
functional; the file just ends up with inherited ACLs.

Test plan:
  - bun test browse/test/file-permissions.test.ts — 13 pass, 0 fail (POSIX
    mode-bit assertions, Windows no-throw, mkdir idempotence, recursive
    creation, Buffer payloads, append-creates-then-reapplies-once semantics)
  - bun test browse/test/security.test.ts — 38 pass, 0 fail (existing security
    test suite plus the bash-binary resolution tests added in fix #1119; the
    converted writeFileSync/appendFileSync/mkdirSync sites in security.ts
    integrate cleanly)
  - Empirical icacls before/after on a real file — 6 ACEs → 1 ACE
  - bun build typecheck on all modified files — clean (server.ts has a
    pre-existing playwright-core/electron resolution issue unrelated to this PR)

POSIX behavior is bit-identical to old code — fs.chmodSync(path, 0o6XX) on the
helper's POSIX branch matches the inline { mode: 0o6XX } it replaces. Linux
and macOS see no behavior change.

Inviting pushback on three judgment calls (in PR description):
  1. icacls vs npm library
  2. ACL scope — just user, or user + SYSTEM?
  3. Graceful degradation — once-per-process warn, not silent, not hard-fail.
This commit is contained in:
Samuel Carson 2026-05-03 16:01:07 -05:00
parent bf65487162
commit dd8402c8b4
12 changed files with 355 additions and 37 deletions

View File

@ -16,6 +16,7 @@
*/
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { validateNavigationUrl } from './url-validation';
import { TabSession, type RefEntry } from './tab-session';
@ -267,10 +268,10 @@ export class BrowserManager {
const fs = require('fs');
const path = require('path');
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
fs.mkdirSync(gstackDir, { recursive: true });
mkdirSecure(gstackDir);
const authFile = path.join(gstackDir, '.auth.json');
try {
fs.writeFileSync(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }), { mode: 0o600 });
writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }));
} catch (err: any) {
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
}

View File

@ -19,6 +19,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions';
import { isPathWithin } from './platform';
import type { TierPaths } from './browser-skills';
import { defaultTierPaths } from './browser-skills';
@ -74,8 +75,8 @@ export function stageSkill(opts: StageSkillOptions): string {
const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
const stagedDir = path.join(wrapperDir, opts.name);
fs.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 });
fs.mkdirSync(stagedDir, { recursive: true, mode: 0o700 });
mkdirSecure(wrapperDir);
mkdirSecure(stagedDir);
for (const [relPath, contents] of opts.files) {
if (relPath.startsWith('/') || relPath.includes('..')) {

View File

@ -12,6 +12,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
const config = resolveConfig();
@ -729,7 +730,7 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
scopes: pairData.scopes,
expires_at: pairData.expires_at,
};
fs.writeFileSync(configFile, JSON.stringify(configData, null, 2), { mode: 0o600 });
writeSecureFile(configFile, JSON.stringify(configData, null, 2));
console.log(`Connected. ${localHost} can now use the browser.`);
console.log(`Config written to: ${configFile}`);
} catch (err: any) {

View File

@ -12,6 +12,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { mkdirSecure } from './file-permissions';
export interface BrowseConfig {
projectDir: string;
@ -81,7 +82,7 @@ export function resolveConfig(
*/
export function ensureStateDir(config: BrowseConfig): void {
try {
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
mkdirSecure(config.stateDir);
} catch (err: any) {
if (err.code === 'EACCES') {
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);

View File

@ -0,0 +1,157 @@
/**
* Cross-platform file permission restriction for sensitive gstack state.
*
* Why this exists
* ----------------
* POSIX mode bits (`0o600` for files, `0o700` for dirs) are how gstack marks
* sensitive state files auth tokens, canary tokens, chat history, agent
* queue, device salt, per-tab security decisions. On Linux and macOS,
* `fs.chmodSync(path, 0o600)` and `fs.writeFileSync(path, data, { mode: 0o600 })`
* do exactly what you'd hope: the file ends up readable and writable only
* by the owning user, no access for group / other.
*
* On Windows, both calls are effectively no-ops. NTFS uses ACLs, not POSIX
* mode bits, and Node's fs module doesn't translate. So on every Windows
* install, sensitive gstack state files inherit whatever ACL the parent
* directory grants typically user-full + inherited admin-full. That's
* fine on a single-user laptop but leaks on:
*
* - Self-hosted CI runners (GitHub Actions / GitLab / Jenkins agents
* running as a different service account on the same box they can
* read developer state)
* - Shared development machines (agencies, studios, lab machines)
* - Multi-tenant servers with shared home directories
* - Malware running as the same user (no in-user-account isolation)
*
* This module wraps the platform-correct call. POSIX: chmod. Windows:
* icacls with inheritance break + explicit user grant. Failures on either
* platform are best-effort the filesystem is still functional if ACL
* restriction fails; we just don't hit the intended hardening target.
*
* Warning behavior: to avoid spamming the console on a machine where
* icacls is unavailable (rare it ships in System32 on every Windows
* version since 7), we log the first failure per process and stay silent
* afterward. The warning includes the advice "sensitive files may be
* readable by other accounts on this machine" so operators know to audit
* their runner / share setup.
*/
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
let warnedOnce = false;
function warnIcaclsFailure(fsPath: string, err: unknown): void {
if (warnedOnce) return;
warnedOnce = true;
const msg = err instanceof Error ? err.message : String(err);
// biome-ignore lint/suspicious/noConsole: intentional user-facing warning
console.warn(
`[gstack] Failed to restrict Windows ACL on ${fsPath}: ${msg}\n` +
` Sensitive files may be readable by other accounts on this machine.\n` +
` This warning appears once per process; subsequent failures are silent.`
);
}
/**
* Restrict a file to owner-only access (POSIX 0o600 equivalent).
*
* POSIX: `fs.chmodSync(path, 0o600)`. Idempotent if the file was already
* written with `{ mode: 0o600 }`, so safe to call regardless.
*
* Windows: invokes `icacls /inheritance:r /grant:r <user>:(F)` to remove
* any inherited ACLs and replace the ACL with a single entry granting the
* current user full control.
*/
export function restrictFilePermissions(filePath: string): void {
if (process.platform === 'win32') {
try {
const user = os.userInfo().username;
execFileSync(
'icacls',
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
{ stdio: 'ignore' },
);
} catch (err) {
warnIcaclsFailure(filePath, err);
}
return;
}
try { fs.chmodSync(filePath, 0o600); } catch { /* best-effort */ }
}
/**
* Restrict a directory to owner-only access (POSIX 0o700 equivalent),
* with new children inheriting the restricted ACL.
*
* POSIX: `fs.chmodSync(path, 0o700)`. Idempotent if the dir was already
* created with `{ mode: 0o700 }`.
*
* Windows: `icacls /inheritance:r /grant:r <user>:(OI)(CI)(F)`. The
* `(OI)(CI)` flags make new files (OI = object inherit) and subdirs
* (CI = container inherit) inherit the single-user-full ACL important
* because child creations in `fs.writeFileSync(...)` without explicit
* `restrictFilePermissions` still end up owner-only.
*/
export function restrictDirectoryPermissions(dirPath: string): void {
if (process.platform === 'win32') {
try {
const user = os.userInfo().username;
execFileSync(
'icacls',
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
{ stdio: 'ignore' },
);
} catch (err) {
warnIcaclsFailure(dirPath, err);
}
return;
}
try { fs.chmodSync(dirPath, 0o700); } catch { /* best-effort */ }
}
/**
* Write a file and restrict it to owner-only access, cross-platform.
* Replaces `fs.writeFileSync(path, data, { mode: 0o600 })` + Windows ACL.
*/
export function writeSecureFile(
filePath: string,
data: string | NodeJS.ArrayBufferView,
): void {
fs.writeFileSync(filePath, data, { mode: 0o600 });
restrictFilePermissions(filePath);
}
/**
* Append to a file with owner-only permissions, cross-platform.
* Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL.
*
* ACL is applied only on first write subsequent appends are fire-and-forget
* (no need to re-run icacls on every log line).
*/
export function appendSecureFile(
filePath: string,
data: string | NodeJS.ArrayBufferView,
): void {
const existed = fs.existsSync(filePath);
fs.appendFileSync(filePath, data, { mode: 0o600 });
if (!existed) restrictFilePermissions(filePath);
}
/**
* `mkdir -p` with owner-only directory permissions, cross-platform.
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
* Safe to call on an existing directory re-applies the ACL idempotently.
*/
export function mkdirSecure(dirPath: string): void {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
restrictDirectoryPermissions(dirPath);
}
/**
* Reset the once-per-process warning gate. Test-only.
*/
export function __resetWarnedForTests(): void {
warnedOnce = false;
}

View File

@ -16,6 +16,7 @@ export { validateOutputPath, escapeRegExp } from './path-security';
import * as Diff from 'diff';
import * as fs from 'fs';
import * as path from 'path';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { TEMP_DIR } from './platform';
import { resolveConfig } from './config';
import type { Frame } from 'playwright';
@ -917,7 +918,7 @@ export async function handleMetaCommand(
const config = resolveConfig();
const stateDir = path.join(config.stateDir, 'browse-states');
fs.mkdirSync(stateDir, { recursive: true });
mkdirSecure(stateDir);
const statePath = path.join(stateDir, `${name}.json`);
if (action === 'save') {
@ -929,7 +930,7 @@ export async function handleMetaCommand(
cookies: state.cookies,
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
};
fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 });
writeSecureFile(statePath, JSON.stringify(saveData, null, 2));
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠ Cookies stored in plaintext. Delete when no longer needed.`;
}

View File

@ -29,6 +29,7 @@ import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions';
import { THRESHOLDS, type LayerSignal } from './security';
import { resolveClaudeCommand } from './claude-bin';
@ -156,7 +157,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
}
async function ensureTestsavantStaged(onProgress?: (msg: string) => void): Promise<void> {
fs.mkdirSync(path.join(TESTSAVANT_DIR, 'onnx'), { recursive: true, mode: 0o700 });
mkdirSecure(path.join(TESTSAVANT_DIR, 'onnx'));
// Small config/tokenizer files
for (const f of TESTSAVANT_FILES) {
@ -301,7 +302,7 @@ export async function scanPageContent(text: string): Promise<LayerSignal> {
// ─── L4c: DeBERTa-v3 ensemble (opt-in) ───────────────────────
async function ensureDebertaStaged(onProgress?: (msg: string) => void): Promise<void> {
fs.mkdirSync(path.join(DEBERTA_DIR, 'onnx'), { recursive: true, mode: 0o700 });
mkdirSecure(path.join(DEBERTA_DIR, 'onnx'));
for (const f of DEBERTA_FILES) {
const dst = path.join(DEBERTA_DIR, f);
if (fs.existsSync(dst)) continue;

View File

@ -24,6 +24,7 @@ import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { writeSecureFile, appendSecureFile, mkdirSecure } from './file-permissions';
// ─── Thresholds + verdict types ──────────────────────────────
@ -344,11 +345,11 @@ function getDeviceSalt(): string {
// fall through to generate
}
try {
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
mkdirSecure(SECURITY_DIR);
} catch {}
cachedSalt = randomBytes(16).toString('hex');
try {
fs.writeFileSync(SALT_FILE, cachedSalt, { mode: 0o600 });
writeSecureFile(SALT_FILE, cachedSalt);
} catch {
// Can't persist (read-only fs, disk full). Keep the in-memory salt
// for this process so cross-log correlation still works within a
@ -456,10 +457,10 @@ export function logAttempt(record: AttemptRecord): boolean {
// the event reported (it goes to a different directory anyway).
reportAttemptTelemetry(record);
try {
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
mkdirSecure(SECURITY_DIR);
rotateIfNeeded();
const line = JSON.stringify(record) + '\n';
fs.appendFileSync(ATTEMPTS_LOG, line, { mode: 0o600 });
appendSecureFile(ATTEMPTS_LOG, line);
return true;
} catch (err) {
// Non-fatal. Log to stderr for debugging but don't block.
@ -489,9 +490,9 @@ export interface SessionState {
*/
export function writeSessionState(state: SessionState): void {
try {
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
mkdirSecure(SECURITY_DIR);
const tmp = `${STATE_FILE}.tmp.${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
writeSecureFile(tmp, JSON.stringify(state, null, 2));
fs.renameSync(tmp, STATE_FILE);
} catch (err) {
console.error('[security] writeSessionState failed:', (err as Error).message);
@ -532,10 +533,10 @@ export interface DecisionRecord {
export function writeDecision(record: DecisionRecord): void {
try {
fs.mkdirSync(DECISIONS_DIR, { recursive: true, mode: 0o700 });
mkdirSecure(DECISIONS_DIR);
const file = decisionFileForTab(record.tabId);
const tmp = `${file}.tmp.${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
writeSecureFile(tmp, JSON.stringify(record));
fs.renameSync(tmp, file);
} catch (err) {
console.error('[security] writeDecision failed:', (err as Error).message);

View File

@ -26,6 +26,7 @@ import {
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
} from './content-security';
import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
import {
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
@ -1477,7 +1478,7 @@ async function start() {
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
const tmpState = config.stateFile + '.tmp';
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2));
fs.renameSync(tmpState, config.stateFile);
return new Response(JSON.stringify({ url: tunnelUrl }), {
@ -2000,7 +2001,7 @@ async function start() {
mode: browserManager.getConnectionMode(),
};
const tmpFile = config.stateFile + '.tmp';
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
writeSecureFile(tmpFile, JSON.stringify(state, null, 2));
fs.renameSync(tmpFile, config.stateFile);
browserManager.serverPort = port;
@ -2081,7 +2082,7 @@ async function start() {
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
const tmpState = config.stateFile + '.tmp';
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2));
fs.renameSync(tmpState, config.stateFile);
} catch (err: any) {
console.error(`[browse] Failed to start tunnel: ${err.message}`);
@ -2111,7 +2112,7 @@ async function start() {
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
stateContent.tunnelLocalPort = tunnelPort;
const tmpState = config.stateFile + '.tmp';
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2));
fs.renameSync(tmpState, config.stateFile);
} catch (err: any) {
console.error(`[browse] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`);
@ -2125,8 +2126,8 @@ start().catch((err) => {
// stderr because the server is launched with detached: true, stdio: 'ignore'.
try {
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`, { mode: 0o600 });
mkdirSecure(config.stateDir);
writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
} catch {
// stateDir may not exist — nothing more we can do
}

View File

@ -23,6 +23,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { safeUnlink } from './error-handling';
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
@ -83,7 +84,7 @@ function findClaude(): string | null {
/** Probe + persist claude availability for the bootstrap card. */
function writeClaudeAvailable(): void {
const stateDir = path.dirname(STATE_FILE);
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
try { mkdirSecure(stateDir); } catch {}
const found = findClaude();
const status = {
available: !!found,
@ -94,7 +95,7 @@ function writeClaudeAvailable(): void {
const target = path.join(stateDir, 'claude-available.json');
const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 });
writeSecureFile(tmp, JSON.stringify(status, null, 2));
fs.renameSync(tmp, target);
} catch {
safeUnlink(tmp);
@ -422,7 +423,7 @@ function handleTabState(msg: {
reason?: string;
}): void {
const stateDir = path.dirname(STATE_FILE);
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
try { mkdirSecure(stateDir); } catch {}
// tabs.json — full list
if (Array.isArray(msg.tabs)) {
@ -442,7 +443,7 @@ function handleTabState(msg: {
const target = path.join(stateDir, 'tabs.json');
const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
writeSecureFile(tmp, JSON.stringify(payload, null, 2));
fs.renameSync(tmp, target);
} catch {
safeUnlink(tmp);
@ -457,11 +458,11 @@ function handleTabState(msg: {
const ctxFile = path.join(stateDir, 'active-tab.json');
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify({
writeSecureFile(tmp, JSON.stringify({
tabId: active.tabId ?? null,
url: active.url,
title: active.title ?? '',
}), { mode: 0o600 });
}));
fs.renameSync(tmp, ctxFile);
} catch {
safeUnlink(tmp);
@ -477,11 +478,11 @@ function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }):
const ctxFile = path.join(stateDir, 'active-tab.json');
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify({
writeSecureFile(tmp, JSON.stringify({
tabId: msg.tabId ?? null,
url,
title: msg.title ?? '',
}), { mode: 0o600 });
}));
fs.renameSync(tmp, ctxFile);
} catch {
safeUnlink(tmp);
@ -524,9 +525,9 @@ function main() {
// Write port file atomically so the parent server can pick it up.
const dir = path.dirname(PORT_FILE);
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {}
try { mkdirSecure(dir); } catch {}
const tmp = `${PORT_FILE}.tmp-${process.pid}`;
fs.writeFileSync(tmp, String(port), { mode: 0o600 });
writeSecureFile(tmp, String(port));
fs.renameSync(tmp, PORT_FILE);
// Hand the parent the internal token so it can call /internal/grant.
@ -549,8 +550,8 @@ function main() {
// to a state file the parent reads. This avoids env-passing races. See main().
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
try {
fs.mkdirSync(path.dirname(INTERNAL_TOKEN_FILE), { recursive: true, mode: 0o700 });
fs.writeFileSync(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN, { mode: 0o600 });
mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE));
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
} catch {}
main();

View File

@ -18,6 +18,7 @@
import { promises as fsp } from 'fs';
import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions';
const LOG_DIR = path.join(os.homedir(), '.gstack', 'security');
const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl');
@ -31,7 +32,10 @@ let dirEnsured = false;
async function ensureDir(): Promise<void> {
if (dirEnsured) return;
try {
await fsp.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
// Sync mkdir is fine here — runs once per process at first denial. The
// (OI)(CI) inheritance set on Windows means subsequent fsp.appendFile
// writes pick up the owner-only ACL automatically.
mkdirSecure(LOG_DIR);
dirEnsured = true;
} catch {
// Swallow — log writes are best-effort. Failure to mkdir just means

View File

@ -0,0 +1,148 @@
/**
* Unit tests for browse/src/file-permissions.ts
*
* Strategy:
* - POSIX assertions check fs.statSync.mode bits directly (cheap, reliable,
* runs on every CI config).
* - Windows assertions don't check ACLs (that'd require parsing icacls
* output, which is brittle across Windows versions / locales). Instead
* we verify the helper doesn't throw and the file ends up accessible
* to the current user the "doesn't crash, file still usable"
* contract the callers rely on.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
restrictFilePermissions,
restrictDirectoryPermissions,
writeSecureFile,
appendSecureFile,
mkdirSecure,
__resetWarnedForTests,
} from '../src/file-permissions';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-perms-'));
__resetWarnedForTests();
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
});
describe('restrictFilePermissions', () => {
test('on POSIX, sets file mode to 0o600', () => {
if (process.platform === 'win32') return;
const p = path.join(tmpDir, 'secret');
fs.writeFileSync(p, 'token');
fs.chmodSync(p, 0o644); // start world-readable to prove the call mutates it
restrictFilePermissions(p);
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
});
test('on Windows, does not throw on an existing file', () => {
if (process.platform !== 'win32') return;
const p = path.join(tmpDir, 'secret');
fs.writeFileSync(p, 'token');
expect(() => restrictFilePermissions(p)).not.toThrow();
// File remains readable by the caller — core contract.
expect(fs.readFileSync(p, 'utf8')).toBe('token');
});
test('on Windows, does not throw when icacls fails (bad path)', () => {
if (process.platform !== 'win32') return;
// icacls emits an error for a nonexistent path; helper must swallow.
expect(() => restrictFilePermissions(path.join(tmpDir, 'nonexistent'))).not.toThrow();
});
});
describe('restrictDirectoryPermissions', () => {
test('on POSIX, sets directory mode to 0o700', () => {
if (process.platform === 'win32') return;
const d = path.join(tmpDir, 'subdir');
fs.mkdirSync(d, { mode: 0o755 });
restrictDirectoryPermissions(d);
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
});
test('on Windows, does not throw on an existing directory', () => {
if (process.platform !== 'win32') return;
const d = path.join(tmpDir, 'subdir');
fs.mkdirSync(d);
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
});
});
describe('writeSecureFile', () => {
test('writes the payload and restricts permissions atomically', () => {
const p = path.join(tmpDir, 'data');
writeSecureFile(p, 'hello');
expect(fs.readFileSync(p, 'utf8')).toBe('hello');
if (process.platform !== 'win32') {
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
}
});
test('accepts Buffer payloads', () => {
const p = path.join(tmpDir, 'buffer');
writeSecureFile(p, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
const out = fs.readFileSync(p);
expect(out.length).toBe(4);
expect(out[0]).toBe(0xde);
});
test('overwrites existing file', () => {
const p = path.join(tmpDir, 'existing');
fs.writeFileSync(p, 'old', { mode: 0o644 });
writeSecureFile(p, 'new');
expect(fs.readFileSync(p, 'utf8')).toBe('new');
});
});
describe('appendSecureFile', () => {
test('appends to a new file and sets owner-only permissions', () => {
const p = path.join(tmpDir, 'log');
appendSecureFile(p, 'line1\n');
expect(fs.readFileSync(p, 'utf8')).toBe('line1\n');
if (process.platform !== 'win32') {
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
}
});
test('appends without re-applying ACL on subsequent writes', () => {
const p = path.join(tmpDir, 'log');
appendSecureFile(p, 'line1\n');
appendSecureFile(p, 'line2\n');
expect(fs.readFileSync(p, 'utf8')).toBe('line1\nline2\n');
});
});
describe('mkdirSecure', () => {
test('creates directory with owner-only mode (POSIX)', () => {
if (process.platform === 'win32') return;
const d = path.join(tmpDir, 'nested', 'deep');
mkdirSecure(d);
expect(fs.statSync(d).isDirectory()).toBe(true);
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
});
test('is idempotent — safe to call on existing directory', () => {
const d = path.join(tmpDir, 'dir');
mkdirSecure(d);
expect(() => mkdirSecure(d)).not.toThrow();
});
test('recursive behavior: creates intermediate directories', () => {
const d = path.join(tmpDir, 'a', 'b', 'c');
mkdirSecure(d);
expect(fs.existsSync(path.join(tmpDir, 'a'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
expect(fs.existsSync(d)).toBe(true);
});
});