mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
* 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>
371 lines
15 KiB
TypeScript
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;
|
|
}
|