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
5 changes: 3 additions & 2 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
}
Expand Down
5 changes: 3 additions & 2 deletions browse/src/browser-skill-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('..')) {
Expand Down
3 changes: 2 additions & 1 deletion browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion browse/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import * as fs from 'fs';
import * as path from 'path';
import { mkdirSecure } from './file-permissions';

export interface BrowseConfig {
projectDir: string;
Expand Down Expand Up @@ -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`);
Expand Down
157 changes: 157 additions & 0 deletions browse/src/file-permissions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 3 additions & 2 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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') {
Expand All @@ -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.`;
}

Expand Down
5 changes: 3 additions & 2 deletions browse/src/security-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 9 additions & 8 deletions browse/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 7 additions & 6 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }), {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand All @@ -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
}
Expand Down
Loading