fix(browse): NTFS ACL hardening for Windows state files via icacls (complements v1.24)#1308
Open
scarson wants to merge 1 commit intogarrytan:mainfrom
Open
fix(browse): NTFS ACL hardening for Windows state files via icacls (complements v1.24)#1308scarson wants to merge 1 commit intogarrytan:mainfrom
scarson wants to merge 1 commit intogarrytan:mainfrom
Conversation
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 garrytan#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.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
gstack's
~/.gstack/state directory holds bearer tokens (.auth.json), canary tokens, agent queue contents (with prompt history), session state, security-decision logs, and saved cookie bundles — all written with{ mode: 0o600 }or appended with the same. 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.Refresh of #1121 (closed in favor of this PR), rebased against current main and extended to cover terminal-agent.ts + tunnel-denial-log.ts (both new since v1.4). Orthogonal to v1.24.0.0's binary-resolution work but complementary at the write side: v1.24's
bin/gstack-pathsresolves~/.gstack/correctly across plugin / global / local installs (#1252); this PR ensures files written into those resolved paths actually get the POSIX 0o600 semantic translated to NTFS via icacls.Empirical proof
Fresh file written on Windows 11 with
fs.writeFileSync(p, 'secret', { mode: 0o600 }), then inspected via icacls:Six ACEs — the one I actually want (
ARTEMIS\Sam) is the LAST of six. The{ mode: 0o600 }hint did nothing. After applying this PR's helper:One ACE. POSIX 0o600 semantic translated correctly.
Threat model
The "inherited by 6 principals" state is merely aesthetic on a single-user laptop where all those principals map back to the same human. It's a real leak on:
The agent queue contains the canary token, which is the secret that backstops prompt-injection defense. Letting another user read that defeats one of the security classifier's core layers.
The fix
A new
browse/src/file-permissions.ts(158 LOC) with two primitives and three drop-in wrappers:(OI)(CI)on directories means new child files (object inherit) and subdirs (container inherit) inherit the single-user-full ACL — important because anyfs.writeFileSync(...)inside anmkdirSecure-created dir will land already-restricted without further work. This is what makes the asyncfsp.appendFilecalls intunnel-denial-log.tscorrect without needing an async helper variant.19 call sites converted across 9 source files (extends the original PR by terminal-agent.ts + tunnel-denial-log.ts, both new since the original was written in April):
browse/src/server.tsbrowse/src/security.tsbrowse/src/terminal-agent.tsbrowse/src/security-classifier.tsbrowse/src/browser-skill-write.tsbrowse/src/browser-manager.ts(.auth.json— bearer token)browse/src/cli.tsbrowse/src/config.tsbrowse/src/meta-commands.tsbrowse/src/tunnel-denial-log.tsError handling
icacls failures (nonexistent path, missing icacls.exe, hardened environments that block it) 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.
Design notes — inviting pushback
This PR introduces a new pattern to gstack (shell-out to icacls). Three judgment calls worth your read:
icacls vs. an npm library. I used
execFileSync('icacls', ...)— the Win32 native tool that ships in System32 on every Windows install since 7. Alternative is something likewin-permissions(typed wrapper around icacls). I'd lean shell-out (one fewer dependency, no npm-supply-chain exposure for a security-critical function), but happy to swap.ACL scope: just the user, or user + SYSTEM? The helper grants ONLY the current user — matches POSIX 0o600 exactly. An argument for adding SYSTEM: backup / antivirus services run as SYSTEM and may need read access. An argument against: gstack state files are small, ephemeral, and recreated on each session. I went with "just user" to be deliberately strict; happy to relax to
SYSTEM:(F),<user>:(F)if you prefer.Graceful degradation on icacls failure. Log once and continue. Alternatives: silent (lose audit trail), log per failure (spammy), hard-fail (crash the server on a security hardening failure — wrong). Once-per-process compromise seemed right.
What changed from #1121
mkdirSecureAsync.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 suite + the new tests added in companion PR fix(browse): bash.exe wrap for telemetry on Windows (v1.24 extension) #1306; the converted writeFileSync/appendFileSync/mkdirSync sites in security.ts integrate cleanly)bun buildtypecheck on all 9 modified files — clean (server.ts has a pre-existing playwright-core/electron resolution issue unrelated to this PR)fs.chmodSync(path, 0o6XX)matches the inline{ mode: 0o6XX }it replaces).What this PR doesn't do
~/.gstack/ngrok.envis created by the user (we only read it), so we can't restrict its ACL at write time.~/.gstack/and run icacls on everything — out of scope; happy to add if you want it.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need.