Files
gstack/browse/src/file-permissions.ts
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

371 lines
15 KiB
TypeScript

/**
* 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, PTY session state, tab context. 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;
let cachedSid: string | null | undefined;
/**
* Resolve the current user's SID, cached for the process lifetime.
*
* Returns null if `whoami` is unavailable or its output cannot be parsed,
* in which case callers fall back to a domain-qualified account name.
*/
function currentUserSid(): string | null {
if (cachedSid !== undefined) return cachedSid;
try {
// Pin to the System32 binary. A bare `whoami` resolves to the MSYS/Git
// Bash build under a bash-flavoured PATH, which rejects `/user` — the
// lookup would then silently fail on one of the most common Windows
// setups for this tool.
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
const out = execFileSync(`${systemRoot}\\System32\\whoami.exe`, ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf8',
windowsHide: true,
});
const match = out.match(/S-1-[\d-]+/);
cachedSid = match ? match[0] : null;
} catch {
cachedSid = null;
}
return cachedSid;
}
/**
* The principal to hand icacls for "the current user".
*
* An unqualified username is ambiguous: on a machine whose hostname equals
* the username, it fails to resolve to the user account and icacls silently
* writes an ACE for the machine SID instead. Combined with `/inheritance:r`
* that leaves a directory whose only ACE matches nobody — locking out the
* process that just created it.
*
* `*<SID>` is icacls' literal-SID form and is immune to that ambiguity.
* The domain-qualified name is the fallback.
*/
function currentUserPrincipal(): string {
const sid = currentUserSid();
if (sid) return `*${sid}`;
const domain = process.env.USERDOMAIN || os.hostname();
return `${domain}\\${os.userInfo().username}`;
}
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 = currentUserPrincipal();
execFileSync(
'icacls',
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
{ stdio: 'ignore', windowsHide: true },
);
} 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 }`.
*
* Owner-only hardening is for directories gstack creates and owns. Two
* pre-existing shapes must never be chmodded, so the POSIX branch refuses
* them:
*
* - A directory owned by another user. On most hosts the chmod just
* fails EPERM, but run as root (or with CAP_FOWNER — containers, CI
* sandboxes) it SUCCEEDS and takes the directory away from its owner.
* - A shared sticky world-writable directory (`/tmp`, `/var/tmp`).
* Callers land here when a state file is configured directly inside
* the system temp dir (`BROWSE_STATE_FILE=/tmp/foo.json` makes
* `path.dirname()` derive `/tmp` as the state dir). A 0o700 `/tmp`
* locks every other process on the machine out of it: access(2)-based
* checks (`fs.existsSync`) return EACCES→false machine-wide, even
* while stat keeps working for capability-holding processes.
*
* 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.
*
* Symlinked dirs are warned about and SKIPPED, never followed: both
* `chmod` and `icacls` dereference the link, so restricting through a
* symlink hardens whatever the link points at — a target the caller never
* vetted (and, with `/inheritance:r`, one we could lock its real owner out
* of). Skipping is best-effort-consistent with the rest of this module:
* the filesystem stays functional, we just don't hit the hardening target.
*/
export function restrictDirectoryPermissions(dirPath: string): void {
try {
if (fs.lstatSync(dirPath).isSymbolicLink()) {
// biome-ignore lint/suspicious/noConsole: intentional user-facing warning
console.warn(
`[gstack] Refusing to restrict permissions through symlink ${dirPath} — skipping.\n` +
` Restricting through a symlink would alter the link target instead. ` +
`Harden the real directory directly.`
);
return;
}
} catch {
// Path doesn't exist (or lstat failed) — fall through; both platform
// branches below already swallow failures on missing paths.
}
if (process.platform === 'win32') {
try {
const user = currentUserPrincipal();
execFileSync(
'icacls',
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
{ stdio: 'ignore', windowsHide: true },
);
} catch (err) {
warnIcaclsFailure(dirPath, err);
}
return;
}
try {
// fd-anchored check-then-act: statSync + chmodSync resolve the path twice,
// so a symlink swapped in between would let the chmod land on a directory
// the check never saw (exactly the CAP_FOWNER hosts this guard exists
// for). O_NOFOLLOW refuses a symlinked state dir outright; fstat + fchmod
// pin both the check and the act to the same inode.
const fd = fs.openSync(
dirPath,
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
);
try {
if (!shouldHardenDir(fs.fstatSync(fd))) {
warnUnhardenedDirOnce(dirPath);
return;
}
fs.fchmodSync(fd, 0o700);
} finally {
fs.closeSync(fd);
}
} catch (err: any) {
// The fd path refuses two shapes the old chmodSync handled, and a silent
// skip here would quietly lose hardening (the module's own rule is that
// refusals are never silent):
// ELOOP/ENOTDIR — dirPath is a symlink (dotfiles-managed ~/.gstack via
// stow/chezmoi). Never follow it blind; warn so the operator knows.
// EACCES — an OWNED dir stuck without the read bit (mode 0300/0000)
// can't be opened but could always be repaired by plain chmod. Keep
// that self-repair: re-check ownership via lstat (no link follow) and
// chmod only a real, owned, non-shared directory.
if (err?.code === 'EACCES') {
try {
const st = fs.lstatSync(dirPath);
if (st.isDirectory() && shouldHardenDir(st)) {
fs.chmodSync(dirPath, 0o700);
return;
}
} catch { /* fall through to the warning */ }
}
if (err?.code === 'ELOOP' || err?.code === 'ENOTDIR' || err?.code === 'EACCES') {
warnUnhardenedDirOnce(dirPath);
}
/* anything else: best-effort, matching the old behavior */
}
}
/** Owner-only hardening applies to a real dir we own that isn't shared. */
function shouldHardenDir(st: fs.Stats): boolean {
// chmod authorization is judged on the EFFECTIVE uid; fall back to the
// real uid where geteuid is unavailable (Windows — unreachable here).
const uid = process.geteuid?.() ?? process.getuid?.();
if (st.uid !== uid) return false;
// sticky + world-writable = shared temp dir (/tmp, /var/tmp) — never ours
// to restrict. Running as root, treat ANY world-writable dir as shared:
// root "owns" docker/CI volume mounts (0777, no sticky bit) that other
// users depend on, and a 0700 there locks them all out.
if ((st.mode & 0o1002) === 0o1002) return false;
if (uid === 0 && (st.mode & 0o002) === 0o002) return false;
return true;
}
// The POSIX refusal must not be silent (the Windows branch already warns when
// icacls misses its hardening target): an operator who points state at a
// shared or foreign-owned directory should learn it was left unhardened.
const unhardenedWarned = new Set<string>();
function warnUnhardenedDirOnce(dirPath: string): void {
if (unhardenedWarned.has(dirPath)) return;
unhardenedWarned.add(dirPath);
// biome-ignore lint/suspicious/noConsole: intentional user-facing warning
console.warn(
`[gstack] directory ${dirPath} is shared, symlinked, or owned by another user; ` +
'gstack left its permissions untouched — use a private, non-symlinked directory for owner-only hardening',
);
}
/**
* 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);
const payload = typeof data === 'string' ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
fs.appendFileSync(filePath, payload, { mode: 0o600 });
if (!existed) restrictFilePermissions(filePath);
}
/**
* Windows only: probe whether the current process can actually list the
* directory. `fs.accessSync` doesn't consult NTFS ACLs on Windows, so a
* real readdir is the only honest check.
*/
function canListDir(dirPath: string): boolean {
try { fs.readdirSync(dirPath); return true; } catch { return false; }
}
/**
* Windows only: repair a broken DACL on a state directory (#1605).
*
* `icacls /inheritance:r /grant:r <user>:(F)` is a single command, but the
* two halves can partially fail: inheritance gets stripped while the user
* grant doesn't resolve (localized account names, domain accounts, roaming
* profiles). The result is a DACL with no usable ACE — often just a machine
* SID — and the client can't read its own state files. `/reset` restores
* inherited ACLs from the parent, making the directory functional again.
* Functional-but-unhardened beats hardened-but-unusable.
*/
export function repairBrokenDacl(dirPath: string): void {
if (process.platform !== 'win32') return;
try {
execFileSync('icacls', [dirPath, '/reset', '/T', '/C', '/Q'], { stdio: 'ignore', windowsHide: true });
} catch (err) {
warnIcaclsFailure(dirPath, err);
}
}
function isExistingDirectory(dirPath: string): boolean {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
/**
* `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,
* except on directories gstack doesn't own (another user's dir, or a shared
* sticky world-writable dir like `/tmp`), which are left untouched — see
* `restrictDirectoryPermissions`.
*
* Windows: after applying the restricted ACL, verifies the directory is
* still listable by this process and repairs a broken DACL (#1605) if not.
*/
export function mkdirSecure(dirPath: string): void {
try {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
} catch (err: any) {
// Bun on Windows has thrown EEXIST from a recursive mkdir of an existing
// directory (#2048, #2635); recursive mkdir is idempotent, so only a
// non-directory at the path is a real conflict.
if (err?.code !== 'EEXIST' || !isExistingDirectory(dirPath)) throw err;
}
restrictDirectoryPermissions(dirPath);
if (process.platform === 'win32' && !canListDir(dirPath)) {
repairBrokenDacl(dirPath);
restrictDirectoryPermissions(dirPath);
// If re-hardening broke access again, reset once more and leave the
// directory with inherited ACLs — the client must be able to read
// its own state.
if (!canListDir(dirPath)) repairBrokenDacl(dirPath);
}
}
/**
* Reset the once-per-process warning gate. Test-only.
*/
export function __resetWarnedForTests(): void {
warnedOnce = false;
}