Repository navigation
Windows: Smart App Control blocks browse.exe; the CLI has no Node lane (the server does) #2595
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
- Windows 11 with Smart App Control on (default for many clean installs since 22H2).
- Install gstack, build browse.
- 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\<user>\.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.shproducesdist/server-node.mjssrc/cli.tshas anIS_WINDOWSbranch that spawns it withnode, and hard-fails if the bundle is missing:
// 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
/**
* 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:
setuphard-fails withError: bun is required but not installedbun run buildis the only documented build pathbun.exeis unsigned, so Smart App Control blocks it toodist/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
- Wire
build-node-cli.mjsintoscripts/build.shon the Windows branch sobun run buildproduces both bundles. - Improve the error when the CLI cannot launch. Right now Git Bash reports
Permission denied, which points at file permissions rather than code integrity. CheckingMicrosoft-Windows-CodeIntegrity/Operationalfor a matching 3077 and printing "Smart App Control blocked this binary" would save the next person a lot of time. - 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.
Activity
knetics9000 commentedon Aug 16, 2026
Correction
Two claims in the original report were wrong. Flagging them explicitly.
1. Smart App Control does not block
bun.exe. I asserted that without testing it. Measured withGet-AuthenticodeSignatureon the affected machine:bun.exenode.exebrowse.exebun installs and runs fine.
setupandbun run buildboth work.2. The "Residual gap" section is therefore void. I claimed a fresh install could not work on a Smart App Control machine because
setuphard-requires bun. Not true.browse.exeis the only thing that cannot launch, which means PR #2596 is a complete fix rather than a partial one.The root cause is narrower than I first described, and I think it is a sharper argument. It is not the toolchain, it is the artifact.
bun build --compileemits an unsigned executable that is unique to the machine that built it, so it can never be signed and can never accumulate the file reputation Smart App Control accepts in place of a signature. Rebuilding cannot fix it. Any project shipping a locally-compiled unsigned binary hits this.Also, I can now run the test suite (I originally said I could not, on the same mistaken bun assumption).
bun teston the PR branch is identical tomain: 5241 pass / 450 skip / 1435 fail / 12 errors.Unrelated but possibly worth its own issue: 1435 of 7126 tests fail on a fresh clone on Windows before any change. Happy to file that separately with detail.
smartjelic-sys commentedon Aug 25, 2026
Confirming this on a second machine, and adding a tested workaround that needs no certificate.
Environment: Windows 11 Pro 10.0.26200, gstack 1.68.3.0, Smart App Control in enforcement mode (
VerifiedAndReputablePolicyState = 1), blocking policy{0283AC0F-FFF1-49AE-ADA1-8A933130CAD6}. Same CodeIntegrity 3033/3077 events onbrowse.exe. Worth adding: the file has noZone.Identifierstream, so this is not Mark of the Web andUnblock-Fileis a red herring for anyone who lands here from a search.It is not only browse.exe
All five bun-compiled artifacts are unsigned (
Get-AuthenticodeSignaturereportsNotSigned) and all five are blocked:browse/dist/browse.exebrowse/dist/find-browse.exedesign/dist/design.exemake-pdf/dist/pdf.exebin/gstack-global-discover.exeSo
/design-consultation,/design-html,/make-pdfand/skillifygo down alongside the browse-driven skills.The extensionless path is usable on Windows, if it holds a script
The secondary note in #2124 says
dist/browse(no extension) cannot be exec'd from Git Bash. That is true for a PE, but not for a shell script. An extensionless bash script atbrowse/dist/browseexecutes fine and takes precedence over the sibling.exein bash path resolution, which is exactly the pathbrowse/SKILL.mdcomputes for$B. So a source-lane fallback can be dropped in without touching any skill.Verified on the machine above with:
status,goto,urlandscreenshot ./rel.pngall pass, and the daemon starts normally. Two details that matter if this becomes a shipped shim: use an absolute path to the source, and do notcd, because changing cwd breaksgoto file://./...and relative screenshot output paths.The same five-line shape works for the other four binaries against
browse/src/find-browse.ts,design/src/cli.ts,make-pdf/src/cli.tsandbin/gstack-global-discover.ts.bun.exeitself is Authenticode-signed (Codeblog CORP) and runs happily under SAC, so the source lane stays available even when every compiled artifact is blocked. Structurally this is the same move as the macOS ad-hoc codesign block already insetup(around lines 597 to 635): a platform-specific post-build fixup for binaries that will not execute as produced.Related gap: setup cannot detect this
The install gate is
[ ! -x "$BROWSE_BIN" ]. On MSYS that test passes for a SAC-blockedbrowse.exe, because the execute bit is set and code integrity is not consulted until exec time. Sosetupreports success,VERSIONreports the new version, and the failure only surfaces at first skill use as a confusingPermission denied. A one-command smoke run after the build would catch it at install time, and would be the natural trigger for writing the shim.v1.91.60.0 fix(windows): setup degrades instead of dying on a CSO fai…
v1.91.65.0 fix(install): skills only for the agents you installed, ev…
v1.91.60.0 fix(windows): setup degrades instead of dying on a CSO fai…
v1.91.65.0 fix(install): skills only for the agents you installed, ev…