Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 35 additions & 1 deletion package-firewall/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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.

---
Expand All @@ -59,8 +76,25 @@ Running either generator produces these scripts in `out/<namespace>/`:
| `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.
146 changes: 138 additions & 8 deletions package-firewall/powershell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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)
```

---
Expand Down Expand Up @@ -97,6 +101,7 @@ Each script in `out/<namespace>/` 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. |



Expand All @@ -113,18 +118,29 @@ All scripts share a common credential architecture:
```
ENDOR_API_KEY_ID = <key-id>
ENDOR_API_SECRET = <secret>
ENDOR_AUTH_B64 = <base64(key-id:secret)>
ENDOR_ATTR_USER = <attributed username: <console-user>@<machine>>
ENDOR_AUTH_B64 = <base64(attr-user:secret)>
ENDOR_API_SECRET_B64 = <base64(secret)>
ENDOR_NPM_REGISTRY_URL = https://factory.endorlabs.com/v1/namespaces/my-team/firewall/npm/
ENDOR_PYPI_URL = https://<key-id>:<secret>@factory.endorlabs.com/v1/namespaces/my-team/firewall/pypi/simple/
POETRY_HTTP_BASIC_ENDOR_FIREWALL_USERNAME = <key-id>
POETRY_HTTP_BASIC_ENDOR_FIREWALL_USERNAME = <attr-user>
POETRY_HTTP_BASIC_ENDOR_FIREWALL_PASSWORD = <secret>
```

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.

---

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
| `<UserProfile>\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.

<a id="vs-code-notes"></a>
#### 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 <publisher>.<name>
```

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:
Expand All @@ -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.

Expand All @@ -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
(`<console-user>@<machine>`) 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 |

---

Expand Down Expand Up @@ -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`
Expand All @@ -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/<token>` 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. |
Expand Down
Loading
Loading