Skip to content

Windows: Smart App Control blocks browse.exe; the CLI has no Node lane (the server does) #2595

Open

Description

@knetics9000

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\<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.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:
// 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:

  • 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.

Activity

  1. knetics9000 commented on Aug 16, 2026

    @knetics9000
    Author

    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 with Get-AuthenticodeSignature on the affected machine:

    Binary Signature SAC
    bun.exe Valid, CN=Codeblog CORP allowed
    node.exe Valid, CN=OpenJS Foundation allowed
    browse.exe NotSigned blocked

    bun installs and runs fine. setup and bun run build both work.

    2. The "Residual gap" section is therefore void. I claimed a fresh install could not work on a Smart App Control machine because setup hard-requires bun. Not true. browse.exe is 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 --compile emits 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 test on the PR branch is identical to main: 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.

  2. smartjelic-sys commented on Aug 25, 2026

    @smartjelic-sys

    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 on browse.exe. Worth adding: the file has no Zone.Identifier stream, so this is not Mark of the Web and Unblock-File is 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-AuthenticodeSignature reports NotSigned) and all five are blocked:

    • browse/dist/browse.exe
    • browse/dist/find-browse.exe
    • design/dist/design.exe
    • make-pdf/dist/pdf.exe
    • bin/gstack-global-discover.exe

    So /design-consultation, /design-html, /make-pdf and /skillify go 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 at browse/dist/browse executes fine and takes precedence over the sibling .exe in bash path resolution, which is exactly the path browse/SKILL.md computes for $B. So a source-lane fallback can be dropped in without touching any skill.

    Verified on the machine above with:

    #!/usr/bin/env bash
    set -euo pipefail
    _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    _GSTACK_ROOT="$(cd "$_SELF_DIR/../.." && pwd)"
    if command -v bun >/dev/null 2>&1; then _BUN="$(command -v bun)"; else _BUN="$HOME/.bun/bin/bun"; fi
    exec "$_BUN" run "$_GSTACK_ROOT/browse/src/cli.ts" "$@"

    status, goto, url and screenshot ./rel.png all 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 not cd, because changing cwd breaks goto 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.ts and bin/gstack-global-discover.ts.

    bun.exe itself 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 in setup (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-blocked browse.exe, because the execute bit is set and code integrity is not consulted until exec time. So setup reports success, VERSION reports the new version, and the failure only surfaces at first skill use as a confusing Permission 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions