From b2d7209db1aca4877cccd28766bbf519c05ab70a Mon Sep 17 00:00:00 2001 From: Jake Sanders Date: Tue, 4 Aug 2026 15:38:00 -0700 Subject: [PATCH] package-firewall: wire up endor-vscode.ps1 and document the ecosystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final chunk. Completes the Windows side and adds the top-level documentation, which only becomes true once both platforms are in. powershell/templates/vscode.ps1 the ecosystem template powershell/templates/remove.ps1 watcher first, then unpatch per install powershell/templates/script-header.ps1 -NoVSCodeWatcher powershell/generate.ps1 gallery base, block assignment, repatch payload builder powershell/README.md per-script behaviour and prerequisites package-firewall/README.md, README.md ecosystem tables, tests section One behaviour here is worth reading closely. In repatch mode the template must NOT recompute the attributed token: the Scheduled Task can fire at startup with nobody logged in, so $ConsoleUser is empty there, and recomputing would mint a token attributed to no user at all — losing the attribution that makes firewall events traceable to a developer. The prelude reads the URL rendered at install time out of the sidecar state instead, and the template refuses to patch if that URL is empty rather than proceeding with a bad one. The repatch payload is written from the base64 embedded at generation time, not copied from $PSCommandPath: MDM tools routinely run scripts from a temp file that is already gone by the time the task fires. Also fixes stale documentation the VS Code work turned up, predating the user-attribution change: both READMEs conflated generation-time and install-time placeholders, listed NPM_AUTH_B64 / PYPI_URL / PIP_INDEX_URL / GO_PROXY_URL as generation-time when they are install-time fills, omitted the real API_SECRET_B64 and FQDN_HOST, and bash/README.md claimed env.sh still exports ENDOR_PYPI_URL. Tests: 43 assertions over the generated PowerShell. The installer cannot run wholesale off-Windows — its header calls [WindowsIdentity]::GetCurrent() — so the header is stubbed and everything after it is extracted verbatim from the generated file and executed for real: block splitting, token construction, the patch loop, state writes, the watcher call, the repatch counter. The stub is deliberately narrow so what is not covered stays obvious. Among the assertions: the token is recomputed independently from the attribution scheme, and repatch is checked to reuse that same token rather than deriving a new one. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + package-firewall/README.md | 36 ++- package-firewall/powershell/README.md | 146 +++++++++++- package-firewall/powershell/generate.ps1 | 122 +++++++++- .../powershell/templates/remove.ps1 | 25 ++ .../powershell/templates/script-header.ps1 | 9 +- .../powershell/templates/vscode.ps1 | 157 +++++++++++++ package-firewall/tests/README.md | 16 +- package-firewall/tests/powershell/Harness.ps1 | 12 + package-firewall/tests/powershell/e2e.ps1 | 214 ++++++++++++++++++ 10 files changed, 713 insertions(+), 26 deletions(-) create mode 100644 package-firewall/powershell/templates/vscode.ps1 create mode 100644 package-firewall/tests/powershell/e2e.ps1 diff --git a/README.md b/README.md index 4fb070c..82af9b6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Scripts and generators for deploying Endor Labs configuration via MDM. Generates self-contained MDM scripts that configure developer machines to route package installations through the [Endor Package Firewall](https://docs.endorlabs.com/integrations/package-firewall). +Covers JavaScript, Python, Go, Maven, and VS Code extensions. + ### [`agent-governance/`](agent-governance/README.md) Generates MDM-deployable Endor Labs audit hooks for every AI coding agent on your fleet. \ No newline at end of file diff --git a/package-firewall/README.md b/package-firewall/README.md index 5fa25df..1cfc86b 100644 --- a/package-firewall/README.md +++ b/package-firewall/README.md @@ -25,6 +25,18 @@ See each directory's README for generation and deployment instructions. | Python | pip, uv, poetry | | Go | go modules (via GOPROXY) | | Java | Maven (via `~/.m2/settings.xml` mirror); Gradle when it reads `~/.m2` | +| VS Code extensions | VS Code and VS Code Insiders (via `product.json` extension gallery), including `code --install-extension` | + +VS Code has prerequisites the other ecosystems do not — a macOS App Management (TCC) grant, +and a credential that necessarily lands in a world-readable file. Read the +[VS Code prerequisites](bash/README.md#vs-code-prerequisites) before deploying it. + +VS Code is configured by patching `product.json` rather than through VS Code's +`ExtensionGalleryServiceUrl` enterprise policy. That policy looks like the natural fit but is +gated behind a GitHub Copilot Business/Enterprise entitlement, expects a different document +shape, and does not reach `code --install-extension` — +[docs/vscode-enterprise-policy.md](docs/vscode-enterprise-policy.md) records the details and +what would change that. --- @@ -41,9 +53,14 @@ shared/blocks/ ├── pipconf.txt ← pip.conf / pip.ini content ├── uvtoml.txt ← uv.toml content ├── goenv.txt ← go env file content (GOPROXY) -└── mavensettings.txt ← ~/.m2/settings.xml fragment (Maven mirror + server) +├── mavensettings.txt ← ~/.m2/settings.xml fragment (Maven mirror + server) +└── vscodegallery.txt ← product.json extensionsGallery overrides (key-level merge) ``` +`vscodegallery.txt` is the one block that is not written verbatim into a config file: it is +applied as a key-level merge into VS Code's `product.json`, so keys it does not mention are +left exactly as VS Code shipped them. Its syntax is documented in the file itself. + Edit these files to customise what gets written to developer machines. The orchestration scripts (`templates/*.sh` / `templates/*.ps1`) control which files get written and in what order. --- @@ -59,8 +76,25 @@ Running either generator produces these scripts in `out//`: | `endor-go.*` | Configure Go modules only | | `endor-maven.*` | Configure Maven only | | `endor-all.*` | Configure all package managers (single-script deploy) | +| `endor-vscode.*` | Configure VS Code + Insiders extension gallery. **Not** included in `endor-all.*` — deploy alongside it | +| `endor-vscode-repatch.*` | Installed by `endor-vscode.*` and run by the OS after VS Code updates. Written to `out/` only so you can read it; do not upload it | | `endor-remove.*` | Strip all Endor configuration from a machine | +`endor-vscode.*` is deliberately kept out of `endor-all.*`: it is the only script that writes +inside an application bundle and the only one that installs a persistent daemon, so folding it +in would silently widen the blast radius of every existing `endor-all` deployment. + Each generated script is fully self-contained — no external files or dependencies at runtime. > **Security**: add `out/` to `.gitignore`. Generated scripts contain API credentials in plaintext. + +## Tests + +```sh +cd package-firewall/tests && ./run-all.sh +``` + +Covers the VS Code ecosystem: the JSON editing primitives, patch/restore byte fidelity, the +update watcher, and the generated scripts end to end. No root, no network, and nothing touches +an installed VS Code. See [tests/README.md](tests/README.md) for what is and is not covered — +notably, the Windows-only surfaces are reported as skipped rather than passed. diff --git a/package-firewall/powershell/README.md b/package-firewall/powershell/README.md index c7813ce..161bfc6 100644 --- a/package-firewall/powershell/README.md +++ b/package-firewall/powershell/README.md @@ -17,6 +17,7 @@ powershell/ │ ├── js.ps1 ← orchestration: npm / yarn config file writes │ ├── python.ps1 ← orchestration: pip / uv config file writes │ ├── go.ps1 ← orchestration: go env file write +│ ├── vscode.ps1 ← orchestration: product.json gallery patch (JSON-aware) │ ├── maven.ps1 ← orchestration: .m2\settings.xml write (XML-aware) │ └── remove.ps1 ← orchestration: sentinel block + registry env var removal └── out/ ← generated scripts (gitignore this) @@ -25,6 +26,8 @@ powershell/ ├── endor-python.ps1 ├── endor-go.ps1 ├── endor-maven.ps1 + ├── endor-vscode.ps1 + ├── endor-vscode-repatch.ps1 ├── endor-all.ps1 └── endor-remove.ps1 @@ -35,7 +38,8 @@ powershell/ ├── pipconf.txt ← %APPDATA%\pip\pip.ini content ├── uvtoml.txt ← %APPDATA%\uv\uv.toml content ├── goenv.txt ← go env file content (path resolved via `go env GOENV`) -└── mavensettings.txt ← %USERPROFILE%\.m2\settings.xml fragment (Maven mirror + server) +├── mavensettings.txt ← %USERPROFILE%\.m2\settings.xml fragment (Maven mirror + server) +└── vscodegallery.txt ← product.json extensionsGallery overrides (key-level merge) ``` --- @@ -97,6 +101,7 @@ Each script in `out//` is **fully self-contained** — no external fi | `endor-go.ps1` | Team uses Go only | | `endor-maven.ps1` | Team uses Java / Maven only | | `endor-all.ps1` | Team uses multiple ecosystems — single-script deploy | +| `endor-vscode.ps1` | Team uses VS Code and/or VS Code Insiders extensions. **Not** part of `endor-all.ps1` — deploy it alongside. See [VS Code notes](#vs-code-notes) first. | @@ -113,18 +118,29 @@ All scripts share a common credential architecture: ``` ENDOR_API_KEY_ID = ENDOR_API_SECRET = -ENDOR_AUTH_B64 = +ENDOR_ATTR_USER = @> +ENDOR_AUTH_B64 = +ENDOR_API_SECRET_B64 = ENDOR_NPM_REGISTRY_URL = https://factory.endorlabs.com/v1/namespaces/my-team/firewall/npm/ -ENDOR_PYPI_URL = https://:@factory.endorlabs.com/v1/namespaces/my-team/firewall/pypi/simple/ -POETRY_HTTP_BASIC_ENDOR_FIREWALL_USERNAME = +POETRY_HTTP_BASIC_ENDOR_FIREWALL_USERNAME = POETRY_HTTP_BASIC_ENDOR_FIREWALL_PASSWORD = ``` -Config files reference these as `${ENDOR_...}` env var placeholders — the tools expand them at runtime from the process environment. +Config files reference these as `${ENDOR_...}` env var placeholders — the tools expand them at +runtime from the process environment. + +pip, uv, go and VS Code are **not** in that list: none of them can expand env vars in their +config, so those four get literal credentials baked in at install time. `ENDOR_PYPI_URL` and +`ENDOR_GO_PROXY_URL` are computed during the run but deliberately **not** written to the +registry, because nothing reads them there. **Windows advantage over macOS:** `HKCU:\Environment` variables are inherited by every process the user starts — including Makefiles, git hooks, IDE terminals, and scheduled tasks. No shell profile sourcing required. This natively covers the non-interactive context gap. -**Credential rotation**: redeploy the MDM script with new credentials. `HKCU:\Environment` and `pip.ini` (which contains literal credentials) are both updated in place. +**Credential rotation**: redeploy the MDM script with new credentials. `HKCU:\Environment` and +the four literal-credential consumers (pip, uv, go, VS Code) are all updated in place. +`endor-vscode.ps1` detects a rotated credential as `stale`, restores the original +`product.json` gallery, then re-patches it — so the captured original survives any number of +rotations. --- @@ -164,7 +180,7 @@ Writes registry env vars and an Endor-managed block to: | File | Covers | Credentials | |---|---|---| | `%APPDATA%\pip\pip.ini` | pip | Literal — pip cannot expand env vars | -| `%APPDATA%\uv\uv.toml` | uv | `${ENDOR_PYPI_URL}` env var ref | +| `%APPDATA%\uv\uv.toml` | uv | Literal — uv cannot expand env vars | Poetry credentials (`POETRY_HTTP_BASIC_ENDOR_FIREWALL_*`) are written to the registry — no separate config file needed. @@ -196,6 +212,101 @@ Key behaviour: --- +### `endor-vscode.ps1` + +Patches the `extensionsGallery` object in VS Code's `product.json` so extension search, +install, update **and `code --install-extension`** all resolve through the firewall. + +| Install path | Editions | +|---|---| +| `%ProgramFiles%\Microsoft VS Code{, Insiders}\resources\app\product.json` | system-wide | +| `\AppData\Local\Programs\Microsoft VS Code{, Insiders}\...` | per-user | + +Per-user paths are resolved from the **console user's** profile, not `%LOCALAPPDATA%` — +Intune runs as SYSTEM, whose `LOCALAPPDATA` lives under `C:\Windows`, so relying on the env +var would silently miss every per-user install on the fleet. + +Exactly one key is set and one removed; every other key is left as VS Code shipped it, +including keys added by future VS Code versions: + +| Key | Action | Why | +|---|---|---| +| `serviceUrl` | set | VS Code derives both `${serviceUrl}/extensionquery` (search) and `${serviceUrl}/vscode/{publisher}/{name}/latest` (install/update) 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 from `www.vscode-unpkg.net` — bypassing the firewall at precisely the wrong moment. With the key absent that failure retries the firewall's own `extensionquery`, and fails the install if that fails too. Fail-closed. | +| `controlUrl` | untouched | Microsoft's malicious-extension revocation list — free defense in depth alongside Endor | +| `resourceUrlTemplate`, `itemUrl`, `publisherUrl`, `nlsBaseUrl`, `mcpUrl`, `accessSKUs` | untouched | README/changelog rendering, marketplace web views, language packs, Copilot entitlement | + +**How enforcement works.** Blocked versions are filtered out of the gallery response, so they +are never offered and cannot be selected. Extension *downloads* still come from Microsoft's +CDN by design — the asset URLs come from the gallery response, and the firewall filters rather +than rewrites them. So: + +- Keep `*.vsassets.io` and `*.vscode-unpkg.net` reachable through any egress proxy. +- Enforcement is **discovery-time**. Extensions already installed, sideloaded `.vsix` files, + and anything installed before the patch landed are not retroactively caught. + +**Managed marker instead of a sentinel block.** `product.json` is JSON, so it can carry +neither a `#` comment nor an env-var reference. The script adds one top-level key holding the +original `extensionsGallery` verbatim, base64-encoded, so `endor-remove.ps1` restores it +byte-for-byte. Re-running is a no-op when already current; a changed credential or namespace +is detected as `stale`, which restores the original first and then re-patches — the script +never patches on top of a patch. + +Because PowerShell's `ConvertFrom-Json` accepts trailing commas (both the 5.1 and 7.x +implementations do), the validator additionally checks for a dangling comma before `}` or `]` +before installing a patched file. Without that check a corrupt `product.json` would pass +validation here and only fail inside VS Code's own strict parser. + +**The update watcher.** VS Code replaces `product.json` on every update — roughly monthly for +stable, **nightly for Insiders** — on a schedule unrelated to MDM check-in. So +`endor-vscode.ps1` also registers a Scheduled Task at `\Endor\PackageFirewall-VSCode`, running +as `NT AUTHORITY\SYSTEM` with startup, logon and hourly triggers. Task Scheduler has no +file-watch trigger, so the logon trigger stands in for "the user updated, then relaunched" and +the hourly repetition is the real backstop. + +The race is not fully closable: if a developer relaunches VS Code before the task fires, that +one session talks to the public marketplace. It is therefore made *countable* rather than +invisible — each re-apply bumps `repatch_count` in the sidecar state, and later runs print it +(`watcher has re-applied the patch 4x (last: ...)`). Pass `-NoVSCodeWatcher` to skip the task; +the script then says so loudly but still exits 0, because failing every MDM check-in over a +deliberate setting is just alert fatigue. + +Sidecar state lives at `%ProgramData%\Endor\PackageFirewall\vscode\`, ACL-restricted to +`SYSTEM` (resolved from the well-known SID `S-1-5-18`, since that account name is localised on +non-English Windows). It holds the rendered gallery URL, which the Scheduled Task reads back — +the task can fire at startup with nobody logged in, so it cannot recompute the attributed +token itself. + + +#### VS Code notes — read before deploying + +1. **Run as SYSTEM or Administrator**, and note that a running VS Code can hold + `product.json` open. The script checks writability up front and reports a permission + problem as such rather than half-applying a patch. +2. **The gallery token is readable by every user, and that is unavoidable.** `product.json` + must stay readable by everyone who runs VS Code, and the credential is a URL path segment, + so any local user can read a working firewall token — it also appears in VS Code's own + logs. VS Code offers no env-var indirection in `product.json`. Mitigate blast radius, not + exposure: **use a dedicated, separately revocable API key for VS Code**, never the same one + as npm/PyPI/Maven. +3. **Restart VS Code** after install or removal — `product.json` is read once at startup. + +#### Troubleshooting + +A blocked extension produces an **error line in the Output -> Window channel** even though +enforcement is working correctly: + +``` +Error while getting the latest version for the extension . +``` + +That is the expected path: the firewall answers `/latest` with `400`, VS Code classifies it as +a client error and retries `extensionquery`, where the blocked version is filtered out. Set +`Developer: Set Log Level... -> Trace` and filter on `[Marketplace]` to see it. There is no +"Marketplace" output channel — those strings are view names. + +--- + ## Customising To change what gets written to a config file on target machines, edit the relevant file in `../shared/blocks/` directly: @@ -209,6 +320,7 @@ To change what gets written to a config file on target machines, edit the releva | `../shared/blocks/uvtoml.txt` | `%APPDATA%\uv\uv.toml` | | `../shared/blocks/goenv.txt` | `%APPDATA%\go\env` | | `../shared/blocks/mavensettings.txt` | `%USERPROFILE%\.m2\settings.xml` | +| `../shared/blocks/vscodegallery.txt` | VS Code `product.json` → `extensionsGallery` (merged key-by-key, not written verbatim) | To change orchestration logic (which files get written, in what order), edit the relevant `templates/*.ps1` file directly. @@ -219,7 +331,13 @@ Both support the same placeholder syntax as the macOS version: | `{{PLACEHOLDER}}` | Generation time by `generate.ps1` | Values baked into the config file (e.g. registry host) | | `${ENDOR_VAR}` | Runtime by the tool reading the config file | Credential values — resolved from registry env vars | -Available placeholders: `{{API_KEY_ID}}`, `{{API_SECRET}}`, `{{NPM_REGISTRY_URL}}`, `{{NPM_REGISTRY_HOST}}`, `{{NPM_AUTH_B64}}`, `{{PYPI_URL}}`, `{{PIP_INDEX_URL}}`, `{{TRUSTED_HOST}}`, `{{GO_PROXY_URL}}`, `{{MAVEN_REGISTRY_URL}}`, `{{NAMESPACE}}`, `{{FQDN}}` +There are **two** rounds of `{{...}}` substitution, because per-developer attribution +(`@`) does not exist until the script runs on the developer's machine: + +| Placeholder | Resolved | +|---|---| +| `{{NAMESPACE}}`, `{{FQDN}}`, `{{FQDN_HOST}}`, `{{API_KEY_ID}}`, `{{API_SECRET}}`, `{{API_SECRET_B64}}`, `{{NPM_REGISTRY_URL}}`, `{{NPM_REGISTRY_HOST}}`, `{{PYPI_URL}}`, `{{TRUSTED_HOST}}`, `{{MAVEN_REGISTRY_URL}}`, `{{VSCODE_GALLERY_BASE}}` | Generation time, by `Invoke-Substitute` in `generate.ps1` | +| `{{NPM_AUTH_B64}}`, `{{PIP_INDEX_URL}}`, `{{GO_PROXY_URL}}`, `{{VSCODE_GALLERY_URL}}` | **Install time**, by the orchestration template on the developer's machine | --- @@ -263,6 +381,11 @@ Useful for validating what the script will do before deploying to devices. ## Removing the configuration +Deploy `endor-remove.ps1`. It strips the sentinel block from every managed config file, +deletes the Endor `HKCU:\Environment` values and, for VS Code, unregisters the Scheduled Task, +restores the original `extensionsGallery` from the marker, drops the marker, and deletes the +sidecar state. Restart VS Code afterwards. + Deploy `endor-remove.ps1` to strip all Endor configuration from a machine. It: - Removes the sentinel block from `.npmrc`, `.yarnrc.yml`, `pip.ini`, `uv.toml`, the go env file, and `.m2\settings.xml` @@ -278,6 +401,13 @@ Deploy `endor-remove.ps1` to strip all Endor configuration from a machine. It: ## Security notes +| Item | Note | +|---|---| +| VS Code `product.json` | **Contains the gallery token in a file every user can read**, and must, because VS Code reads it as the user and offers no indirection. Use a **dedicated, separately revocable API key** for VS Code so rotation and revocation do not disturb the other ecosystems. | +| VS Code sidecar state | `%ProgramData%\Endor\PackageFirewall\vscode\` — holds the rendered gallery URL for the Scheduled Task. ACL-restricted to `SYSTEM`, so better protected than `product.json` itself. | +| `-DryRun` output | Redacts the VS Code `_ak/` path segment. The other ecosystems echo full credentialed URLs; VS Code deviates deliberately, because this token is a bearer credential in a URL *path* and MDM consoles retain script output for far more people than can read the target file. | + + | Item | Note | |---|---| | `HKCU:\Environment` | Contains credentials as plain REG_SZ strings. Access is restricted to the owning user by default Windows ACLs. | diff --git a/package-firewall/powershell/generate.ps1 b/package-firewall/powershell/generate.ps1 index 46d5533..bd7c2dd 100644 --- a/package-firewall/powershell/generate.ps1 +++ b/package-firewall/powershell/generate.ps1 @@ -78,6 +78,10 @@ $NPM_REGISTRY_URL = "$FQDN/v1/namespaces/$ENDOR_NAMESPACE/firewall/npm/" $NPM_REGISTRY_HOST = "$FQDN_HOST/v1/namespaces/$ENDOR_NAMESPACE/firewall/npm/" $PYPI_URL = "$FQDN/v1/namespaces/$ENDOR_NAMESPACE/firewall/pypi/simple/" $MAVEN_REGISTRY_URL = "$FQDN/v1/namespaces/$ENDOR_NAMESPACE/firewall/maven/" +# VS Code carries its credential as a URL path segment (_ak/) rather than in +# userinfo, so only the base is known here; the token is appended at install time +# once the attribution label exists. +$VSCODE_GALLERY_BASE = "$FQDN/v1/namespaces/$ENDOR_NAMESPACE/firewall/vscode" # -- Output directory ---------------------------------------------------------- $OutDir = Join-Path $ScriptDir "out\$ENDOR_NAMESPACE" @@ -101,6 +105,7 @@ function Invoke-Substitute { $r = $r.Replace('{{PYPI_URL}}', $PYPI_URL) $r = $r.Replace('{{TRUSTED_HOST}}', $TRUSTED_HOST) $r = $r.Replace('{{MAVEN_REGISTRY_URL}}', $MAVEN_REGISTRY_URL) + $r = $r.Replace('{{VSCODE_GALLERY_BASE}}', $VSCODE_GALLERY_BASE) $r } @@ -126,6 +131,7 @@ function Get-AllBlocks { (Get-BlockAssignment 'UV_BLOCK' (Join-Path $SharedBlocksDir 'uvtoml.txt')), (Get-BlockAssignment 'GO_BLOCK' (Join-Path $SharedBlocksDir 'goenv.txt')), (Get-BlockAssignment 'MAVEN_BLOCK' (Join-Path $SharedBlocksDir 'mavensettings.txt')), + (Get-BlockAssignment 'VSCODE_GALLERY_BLOCK' (Join-Path $SharedBlocksDir 'vscodegallery.txt')), '# --', '' ) -join "`n" @@ -156,22 +162,96 @@ if ($EndorWarned) { } '@ -# Build-Script