Skip to content

package-firewall: add VS Code as a target ecosystem - #22

Draft
dekkagaijin wants to merge 1 commit into
mainfrom
vscode-firewall
Draft

package-firewall: add VS Code as a target ecosystem#22
dekkagaijin wants to merge 1 commit into
mainfrom
vscode-firewall

Conversation

@dekkagaijin

@dekkagaijin dekkagaijin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

https://endorlabs.atlassian.net/browse/LM-452

Add VS Code as a package-firewall target ecosystem

Routes VS Code and VS Code Insiders extension traffic through the Endor Package Firewall on
macOS, Linux and Windows. Closes the VS Code extension firewall backlog item.

Why not the ExtensionGalleryServiceUrl enterprise policy

The backlog item proposed the enterprise policy, and it looks like the obvious fit — MDM-native,
survives updates, not user-editable. Verified against the shipping VS Code 1.131.0 bundle and
upstream release/1.99 + main, it can't carry this:

  1. It expects a gallery manifest, not an API root. getExtensionGalleryManifestFromServiceUrl()
    GETs the policy value and parses it as IExtensionGalleryManifest. Our endpoint is the
    _apis/public/gallery analogue.
  2. VS Code gates it behind a GitHub entitlement. No signed-in default account →
    requiresSignIn; otherwise checkAccess() requires a copilot_enterprise_* /
    copilot_for_business_* seat SKU or account.enterprise. A failed gate yields a null
    manifest, which disables the Extensions view entirely — worse than not deploying. Present
    since 1.99.
  3. code --install-extension never consults it. The policy name appears only in
    workbench.desktop.main.js; cliProcessMain.js reads productService.extensionsGallery.serviceUrl
    directly.

Recorded in package-firewall/docs/vscode-enterprise-policy.md
so this doesn't get re-investigated, along with what would unblock it.

What the patch does

One key set, one removed — everything else left as VS Code shipped it, including keys added by
future versions (the writer merges key-by-key, never replacing the object):

Key Action Why
serviceUrl set VS Code derives both ${serviceUrl}/extensionquery and ${serviceUrl}/vscode/{publisher}/{name}/latest from it
extensionUrlTemplate removed It's the fallback when the resource API returns 5xx. Left in place, a firewall outage silently resolves versions from www.vscode-unpkg.net — bypassing the firewall exactly when it's unhealthy. Removed, that failure retries our own extensionquery and fails the install if that fails too. Fail-closed.
controlUrl untouched Microsoft's malicious-extension revocation list — free defense in depth
resourceUrlTemplate, itemUrl, publisherUrl, nlsBaseUrl, mcpUrl, accessSKUs untouched README rendering, web views, language packs, Copilot entitlement

Worth noting on the 400 the factory returns when a version is blocked: that status is exactly
right. 400 classifies as ClientError, which is re-thrown before the unpkg fallback is
consulted, so blocked extensions can never reach Microsoft. Only 5xx could, which is what
removing extensionUrlTemplate closes.

Managed marker, not a sentinel block

product.json is JSON, so it can carry neither a # comment nor an ${ENDOR_*} reference. The
scripts add one top-level key holding the original extensionsGallery verbatim (base64), so
removal restores it byte-for-byte. Re-running is a genuine no-op when current; a rotated
credential is detected as stale and triggers restore-then-patch, so the captured original
survives any number of rotations.

Update watcher

VS Code replaces product.json on every update — monthly for stable, nightly for Insiders
uncorrelated with MDM check-in, so a watcher re-applies it: launchd WatchPaths (macOS), systemd
.path/.timer or hourly cron (Linux), Scheduled Task (Windows). Each watches the file and its
parent directory, since the updater swaps the whole directory.

The residual race isn't closable — if a developer relaunches VS Code before the watcher fires, that
session talks to the public marketplace. It's made countable instead of invisible: each re-apply
bumps repatch_count, and later runs print watcher has re-applied the patch 4x (last: …).
--no-vscode-watcher / -NoVSCodeWatcher opts out loudly but still exits 0 — failing every
check-in over a deliberate setting is alert fatigue.

New prerequisites — please review these specifically

  • macOS Ventura+ needs the App Management (SystemPolicyAppBundles) TCC grant for the MDM
    agent. Root is not exempt. This is a prerequisite the repo has never had; without it the
    script fails loudly with an explanation rather than silently no-op'ing.
  • The gallery token lands in world-readable product.json (0644) and cannot not. VS Code reads
    it as the user and offers no indirection. Any local user can read a working firewall credential,
    and it appears in VS Code's own logs. Mitigation is blast radius only: use a dedicated,
    separately revocable API key for VS Code.
  • Extension downloads still come from Microsoft's CDN by design — enforcement is
    filtering-by-omission, so *.vsassets.io / *.vscode-unpkg.net must stay reachable.
  • Enforcement is discovery-time: already-installed extensions and sideloaded .vsix aren't
    retroactively caught.
  • codesign --verify will report the bundle as modified. Expected. Do not re-sign — ad-hoc
    re-signing strips hardened-runtime entitlements and would durably break stored GitHub auth.

endor-vscode.* is deliberately not folded into endor-all.*: it's the only script that
writes inside an application bundle and the only one installing a persistent daemon, so folding it
in would silently widen the blast radius of every existing endor-all deployment.

Testing

263 assertions across 7 suites, all passing. Exercised against the real
/Applications/Visual Studio Code.app product.json (1.131.0) plus synthetic fixtures
(4-space-indented, single-entry, minified, CRLF).

Suite
bash JSON primitives 31
bash lib (stripped, as inlined) 55
bash watcher (plist lints, systemd units, cron, telemetry) 19
bash e2e (generated scripts, sandboxed install) 37
PowerShell JSON primitives 41
PowerShell VS Code lifecycle 46 (+2 Windows-only skips)
PowerShell e2e (generated script bodies) 34

Covered end-to-end: install → idempotent re-run → simulated VS Code update → watcher repatch →
credential rotation → byte-exact removal, plus the EPERM/TCC path, snap/flatpak refusal, 5xx
fail-closed behaviour, and dry-run token redaction.

Six real bugs were caught by these tests and fixed: a gawk-only for-init that BSD awk rejects;
trailing-newline fidelity (shipped product.json has none, awk always adds one, which would have
made byte-exact restore impossible); the node writer's pretty-printed marker being unreadable by
the single-line marker parser; set -e aborting the generated script on the benign
"already current" return; PowerShell's ConvertFrom-Json silently accepting trailing commas; and
the PS template recomputing the attributed token in repatch mode, where no console user exists —
which would have minted a token attributed to nobody.

Not included / follow-ups

  • The test suites are not in this PR. The repo has no test convention, so adding one felt like
    scope creep on top of an already large change — happy to add them under package-firewall/tests/
    in a follow-up if you'd like them, and given what they caught I'd recommend it.
  • PowerShell is validated but not Windows-validated. pwsh 7.6.4 was used to exercise the JSON
    editing, state machine, marker handling, both writer paths and the template bodies. Scheduled Task
    registration and %ProgramFiles% / AppData discovery cannot run off-Windows and are explicitly
    skipped — those two need a Windows box before shipping.
  • Also fixes stale docs predating the user-attribution change: both READMEs conflated
    generation-time and install-time placeholders and still described pip/uv/go credentials as env-var
    references.

@dekkagaijin
dekkagaijin marked this pull request as draft August 4, 2026 19:53
Routes VS Code and VS Code Insiders extension traffic through the Endor Package
Firewall on macOS, Linux and Windows, by patching the extensionsGallery object in
product.json.

Why product.json and not the ExtensionGalleryServiceUrl enterprise policy — that
policy looks like the obvious fit, but (a) it expects the URL to return an
IExtensionGalleryManifest document rather than a marketplace API root, (b) VS Code
gates it behind a signed-in GitHub account holding a Copilot Business/Enterprise
seat, and a failed gate disables the Extensions view entirely, and (c) it is never
consulted by `code --install-extension`. Details and what would unblock it are in
package-firewall/docs/vscode-enterprise-policy.md.

The patch sets one key and removes one:

  serviceUrl            -> the firewall's /firewall/vscode/_ak/<token> endpoint;
                           VS Code derives both /extensionquery and
                           /vscode/{publisher}/{name}/latest from it
  extensionUrlTemplate  -> removed. It is the fallback VS Code uses when the
                           resource API returns 5xx; left in place, a firewall
                           outage would silently resolve versions straight from
                           www.vscode-unpkg.net. Removing it redirects that
                           failure into the firewall's own extensionquery.

controlUrl (Microsoft's malicious-extension revocation list), resourceUrlTemplate,
itemUrl, publisherUrl, nlsBaseUrl, mcpUrl and accessSKUs are deliberately left
alone, and the writer merges key-by-key so keys added by future VS Code versions
survive.

Because product.json is JSON it can carry neither a sentinel comment nor an env-var
reference, so the managed marker is a top-level key that also stores the original
extensionsGallery verbatim — removal restores it byte-for-byte. Re-running is a
no-op when current; a rotated credential is detected as stale and triggers
restore-then-patch, so the captured original is never lost.

VS Code replaces product.json on every update (monthly for stable, nightly for
Insiders), so an update watcher re-applies the patch: launchd WatchPaths on macOS,
a systemd .path/.timer pair (or hourly cron) on Linux, a Scheduled Task on Windows.
The remaining race is made countable via repatch_count rather than left invisible.

New prerequisites, called out in the READMEs:

  - macOS Ventura+ needs the App Management (SystemPolicyAppBundles) TCC grant for
    the MDM agent; root is not exempt. The script detects EPERM and says so.
  - The gallery token lands in world-readable product.json and cannot not — VS Code
    offers no indirection. Use a dedicated, separately revocable API key.
  - Extension downloads still come from Microsoft's CDN by design, so *.vsassets.io
    and *.vscode-unpkg.net must stay reachable.
  - codesign --verify will report the bundle as modified. Expected; do not re-sign.

endor-vscode.* is deliberately not folded into endor-all.*: it is the only script
that writes inside an application bundle and the only one installing a persistent
daemon.

Also fixes stale documentation predating the user-attribution change: the
placeholder tables conflated generation-time and install-time tokens, and both
READMEs still described pip/uv/go credentials as env-var references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant