Windows: Smart App Control blocks browse.exe; the CLI has no Node lane (the server does)
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
> **Corrected 2026-08-16.** Two claims below were wrong: Smart App Control does **not** block `bun.exe` (it is signed), and the "Residual gap" section was therefore also wrong. The real cause is narrower: the *compiled artifact* is unsigned, not the toolchain. See the correction comment for detail. PR #2596 has been reworked accordingly.
## Summary
On Windows 11 with Smart App Control in enforcement mode, `browse/dist/browse.exe` cannot launch. Every `$B` command in `browse/SKILL.md` fails immediately, so `/browse` and every skill that depends on it (`/qa`, `/design-review`, `/benchmark`, `/canary`) is dead on that machine.
The server half already has a Node lane for Windows. The CLI half does not. Adding one fixes this without disabling any security feature.
## Environment
| | |
|---|---|
| OS | Windows 11 Pro 10.0.26200 (build 26200) |
| gstack | 1.57.10.0 (`a5833c4`) |
| browse dist | `a5833c413f98b13f105beac96262e8098b628461` |
| Node | v24.19.0 |
| Smart App Control | enforcement (`VerifiedAndReputablePolicyState = 1`) |
## Reproduction
1. Windows 11 with Smart App Control on (default for many clean installs since 22H2).
2. Install gstack, build browse.
3. Run any browse command.
```
PS> & "$env:USERPROFILE\.claude\skills\gstack\browse\dist\browse.exe" status
Program 'browse.exe' failed to run: An Application Control policy has blocked this file
```
Under Git Bash the same block surfaces as a confusing `Permission denied`, which sends you looking at file permissions instead of code integrity:
```
$ ~/.claude/skills/gstack/browse/dist/browse status
bash: .../browse/dist/browse.exe: Permission denied
```
The real cause is in `Microsoft-Windows-CodeIntegrity/Operational`:
```
Event 3077: Code Integrity determined that a process
(\Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe)
attempted to load \Device\HarddiskVolume3\Users\\.claude\skills\gstack\browse\dist\browse.exe
that did not meet the Enterprise signing level requirements or violated code
integrity policy (Policy ID:{0283ac0f-fff1-49ae-ada1-8a933130cad6}).
Event 3118: Smart App Control Block Details
```
`browse.exe` is a Bun-compiled binary with no Authenticode signature. Smart App Control rejects unsigned executables.
## Why the obvious workarounds do not apply
- **Smart App Control has no per-file allowlist.** There is no UI or registry path to exempt one binary.
- **Turning it off is a one-way door.** Once disabled, Smart App Control cannot be re-enabled without a clean Windows reinstall. Asking users to permanently drop a system-wide protection to run one CLI is not a reasonable ask.
- **Self-signing does not help.** Smart App Control does not trust self-signed certificates.
## Root cause
`browse` is two processes, and only one of them already avoids Bun on Windows.
The **server** has a Node lane, added because Bun cannot drive Playwright's Chromium on Windows (oven-sh/bun#4253, #9911):
- `browse/scripts/build-node-server.sh` produces `dist/server-node.mjs`
- `src/cli.ts` has an `IS_WINDOWS` branch that spawns it with `node`, and hard-fails if the bundle is missing:
```ts
// src/cli.ts
const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null;
if (IS_WINDOWS && !NODE_SERVER_SCRIPT) {
throw new Error('server-node.mjs not found. Run `bun run build` ...');
}
```
The **CLI** has no equivalent. It only ships as the Bun-compiled `dist/browse.exe`. So on Windows the architecture is already "signed Node runs the server", but an unsigned binary is still required to start it.
`node.exe` is signed and Smart App Control allows it. Building the CLI as a Node bundle removes the last unsigned executable from the path.
## Proposed fix
Add a Node lane for the CLI, mirroring the one that already exists for the server.
Good news on feasibility: `src/cli.ts` imports only Node built-ins at the top (`fs`, `path`, `child_process`). Across its whole import graph (`error-handling`, `file-permissions`, `config`, `proxy-config`, `proxy-redact`, `terminal-agent-control`, `socks-bridge`) the only Bun APIs used are:
| API | Already in `bun-polyfill.cjs`? |
|---|---|
| `Bun.spawn` | yes |
| `Bun.spawnSync` | yes |
| `Bun.sleep` | yes |
| `Bun.stdin` | **no** (used only by `chain` reading stdin) |
So the existing polyfill covers everything except a small `Bun.stdin.text()` shim. `import.meta.dir` needs the same rewrite `build-node-server.sh` already does.
### Build script
I wrote this in Node rather than bash on purpose. In PowerShell, `bash` resolves to WSL's `bash.exe`, which fails outright on machines with no WSL distro installed, and that is exactly the population hitting this bug. It also uses esbuild rather than `bun build` so the affected machine can run it at all. If you prefer `bun build` for consistency with `build-node-server.sh`, that works for maintainer and CI builds, but it will not help an affected user build locally (see "Residual gap" below).
browse/scripts/build-node-cli.mjs
```js
/**
* Build a Node.js-compatible CLI bundle for Windows.
*
* The shipped dist/browse.exe is a Bun-compiled binary. It is unsigned, so
* Windows Smart App Control (enforcement mode) refuses to load it. SAC has no
* per-file allowlist, and disabling it is irreversible without a Windows
* reinstall.
*
* The server half already runs under Node. node.exe is signed, so SAC allows
* it. This builds the missing other half: the CLI itself, as a Node bundle, so
* nothing unsigned ever needs to launch.
*/
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import * as fs from 'node:fs';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const BROWSE_DIR = path.resolve(SCRIPT_DIR, '..');
const SRC_DIR = path.join(BROWSE_DIR, 'src');
const DIST_DIR = path.join(BROWSE_DIR, 'dist');
const OUT = path.join(DIST_DIR, 'cli-node.mjs');
console.log('Building Node-compatible CLI bundle...');
// npx is a .cmd shim on Windows, which Node will only launch via a shell. Node
// deprecates (DEP0190) passing an args array alongside shell:true, so build one
// quoted command string instead.
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const quote = (s) => `"${s}"`;
const command = [
npx, '--yes', 'esbuild',
quote(path.join(SRC_DIR, 'cli.ts')),
'--bundle', '--platform=node', '--format=esm',
quote(`--outfile=${OUT}`),
'--external:playwright',
'--external:playwright-core',
'--external:diff',
'--external:bun:sqlite',
'--external:@ngrok/ngrok',
].join(' ');
const build = spawnSync(command, { cwd: BROWSE_DIR, stdio: 'inherit', shell: true });
if (build.status !== 0) {
console.error('esbuild failed.');
process.exit(build.status ?? 1);
}
// import.meta.dir is a Bun-ism. Point it at browse/src so the dev-mode branches
// in resolveServerScript()/resolveNodeServerScript() resolve correctly.
let bundle = fs.readFileSync(OUT, 'utf8').replaceAll('import.meta.dir', '__browseNodeSrcDir');
const header = `// ── Windows Node.js compatibility (auto-generated by build-node-cli.mjs) ──
import { fileURLToPath as _ftp } from "node:url";
import { dirname as _dn } from "node:path";
import { createRequire as _cr } from "node:module";
const __browseNodeSrcDir = _dn(_dn(_ftp(import.meta.url))) + "/src";
{
const _r = _cr(import.meta.url);
_r("./bun-polyfill.cjs");
if (globalThis.Bun && !globalThis.Bun.stdin) {
globalThis.Bun.stdin = {
text() {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { data += chunk; });
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
});
},
};
}
}
// ── end compatibility ──
`;
fs.writeFileSync(OUT, header + bundle);
fs.copyFileSync(path.join(SRC_DIR, 'bun-polyfill.cjs'), path.join(DIST_DIR, 'bun-polyfill.cjs'));
// Launcher wrappers. dist/browse keeps the SKILL.md `$B` resolution working
// verbatim, so no SKILL.md change is needed.
const bashWrapper = `#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
CLI="$DIR/cli-node.mjs"
if [ ! -f "$CLI" ]; then
echo "cli-node.mjs missing. Run: node \\"$DIR/../scripts/build-node-cli.mjs\\"" >&2
exit 1
fi
# node.exe cannot read MSYS-style /c/... paths.
if command -v cygpath >/dev/null 2>&1; then
CLI="$(cygpath -w "$CLI")"
fi
exec node "$CLI" "$@"
`;
const cmdWrapper = `@echo off
node "%~dp0cli-node.mjs" %*
`;
fs.writeFileSync(path.join(DIST_DIR, 'browse'), bashWrapper);
fs.writeFileSync(path.join(DIST_DIR, 'browse.cmd'), cmdWrapper);
try { fs.chmodSync(path.join(DIST_DIR, 'browse'), 0o755); } catch {}
console.log(`Node CLI bundle ready: ${OUT}`);
console.log('Wrappers restored: dist/browse, dist/browse.cmd');
```
### Why a `dist/browse` wrapper
The SETUP block in `browse/SKILL.md` resolves `$B` to `browse/dist/browse` and gates on `[ -x "$B" ]`. Writing the bash wrapper to that exact path means the documented workflow keeps working with no SKILL.md change and no template regeneration. `dist/browse.cmd` covers PowerShell and cmd users.
`browse/dist/` is already gitignored, so only `scripts/build-node-cli.mjs` is a new tracked file.
## What I verified
Run on the affected hardware, both shells:
| Check | Result |
|---|---|
| `browse/SKILL.md` SETUP block, verbatim | `READY: .../dist/browse` |
| `$B goto` + `text`, static page | full text returned |
| `$B goto` + `wait --networkidle` + `text`, JS-rendered SPA | full text returned |
| `$B snapshot -i` | `@e1 [link] "Learn more"` |
| `$B screenshot` | 10,403 byte PNG written |
| `$B console --errors` | CSP errors captured correctly |
| `$B status` (cold start) | server started, `Status: healthy` |
| exit codes on `goto` / `text` | 0 |
| PowerShell via `dist/browse.cmd` | working |
| Git Bash via `dist/browse` | working |
`browse.exe` is left untouched. Nothing unsigned launches.
**I could not run `bun test`.** bun is not installed on this machine, and installing it would very likely hit the same Smart App Control wall since `bun.exe` is also unsigned. So this is verified against real usage but not against your tier 1 suite. Worth a maintainer running `bun test` before merging.
## Residual gap (separate issue, not fixed here)
This restores browse on a machine that already has a built `dist/`. It does not make a **fresh** install work on a Smart App Control machine, because:
- `setup` hard-fails with `Error: bun is required but not installed`
- `bun run build` is the only documented build path
- `bun.exe` is unsigned, so Smart App Control blocks it too
- `dist/` is gitignored, so there is no prebuilt artifact to fall back on
A complete story needs either a bun-free build path or signed release artifacts. That is a much larger change and I did not want to bundle it into this one. Happy to open a separate issue if useful.
## Suggested follow-ups
1. Wire `build-node-cli.mjs` into `scripts/build.sh` on the Windows branch so `bun run build` produces both bundles.
2. Improve the error when the CLI cannot launch. Right now Git Bash reports `Permission denied`, which points at file permissions rather than code integrity. Checking `Microsoft-Windows-CodeIntegrity/Operational` for a matching 3077 and printing "Smart App Control blocked this binary" would save the next person a lot of time.
3. Add a note to the Windows section of the docs. This will hit more people over time, since Smart App Control is on by default for clean Windows 11 installs and only evaluates itself out of enforcement in some cases.
Contributor guide
Research direction
Start with src/cli.ts and compare its Windows handling with browse/scripts/build-node-server.sh. Review the proposed browse/scripts/build-node-cli.mjs and scripts/build.sh integration, then verify the documented commands through the dist/browse and dist/browse.cmd wrappers. Done means the Windows CLI runs without browse.exe and the existing test suite passes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bash, node.js, typescript
- Domain
- build-system, cli, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100