Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
package-firewall/out/
.env
**/out/
.DS_Store
.DS_Store

# agent-governance: stray local generations from render.sh run without -o
# (may carry real credentials — never commit them)
*.tmp
agent-governance/claude-settings.json
agent-governance/cursor-hooks.json
agent-governance/com.anthropic.claudecode.mobileconfig
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ Scripts and generators for deploying Endor Labs configuration via MDM.

### [`package-firewall/`](package-firewall/README.md)

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

### [`agent-governance/`](agent-governance/README.md)

Generates MDM-deployable Endor Labs audit hooks for every AI coding agent on your fleet.
155 changes: 155 additions & 0 deletions agent-governance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# agent-governance

Deploy Endor Labs audit hooks to every AI coding agent on your fleet — Claude Code and Cursor — through your MDM.

## What this is

Endor's AI governance works through **hooks** wired into developer AI tools. Whenever a developer does something — starts a session, runs a tool, edits a file — the hook calls `endorctl ai-audit`, which records the action and, when you turn enforcement on, blocks the ones your policies disallow.

This directory of the public [`mdm-scripts`](https://github.com/endorlabs/mdm-scripts) repo gets those hooks onto every laptop and keeps them current. You run one generator to produce a tool's config, then deliver it however your fleet is managed — an MDM profile, a recurring script, or your config-management tooling. The repo is **credential-free**: your Endor API credentials are supplied at generation time and never stored here.

## Quick start — try it on one machine

Generate a config and drop it in your own user directory. Nothing is enforced this way (a local file isn't tamper-proof), but it's the fastest way to see hooks fire and watch the audit log.

```sh
# Cursor — writes ~/.cursor/hooks.json
scripts/render.sh --agent cursor \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o ~/.cursor/hooks.json

# Claude Code — writes ~/.claude/settings.json
scripts/render.sh --agent claude \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o ~/.claude/settings.json
```

Start a new session in the tool: the hook installs `endorctl` on first run and begins reporting to your Endor namespace. When you're ready to roll this out to the fleet, pick a deployment path below.

## Choose how to deploy

Each tool is delivered the way it best supports it. On macOS, Claude Code takes a tamper-resistant MDM **profile**; Cursor's config is a plain file delivered by a small **script**. A laptop can run any combination.

| Tool | macOS delivery | Why | Runbook |
| --- | --- | --- | --- |
| **Claude Code** | MDM **Custom Profile** (`.mobileconfig`) | Claude reads a managed-settings profile payload (`com.anthropic.claudecode`), enforced by the OS | [Deploy Claude via profile](docs/deploy-claude-profile.md) |
| **Cursor** | MDM **Custom Script** (the runner) | `hooks.json` is a plain file, not a profile payload | [Deploy Cursor via the runner](docs/deploy-cursor-runner.md) |

**Linux** delivers the same JSON as a file — Cursor at `/etc/cursor/hooks.json`, Claude at `/etc/claude-code/managed-settings.json` — via the runner or your config management (Ansible, Chef, …).

**Windows** has no `.mobileconfig`. You pre-generate the config (`--target-os windows`) and push the file with **Intune**; the hook is a self-contained `powershell` command that runs regardless of how the agent launches it. See [Deploy on Windows via Intune](docs/deploy-windows-intune.md).

Other paths: [JumpCloud](docs/deploy-jumpcloud.md) (any OS), and [manual / enterprise-platform install](docs/deploy-manual-enterprise.md) for trials or orgs governing through Cursor Team hooks / Claude's admin console. The full agent × OS × MDM grid is the [support matrix](docs/support-matrix.md).

## The generator

[`scripts/render.sh`](scripts/render.sh) builds a tool's config as JSON. For the Claude macOS profile, pipe that JSON through [`scripts/render-plist.sh`](scripts/render-plist.sh), which wraps it in the profile envelope and converts it to a `.mobileconfig`.

```sh
# Cursor hooks.json
scripts/render.sh --agent cursor \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o hooks.json

# Claude Code settings.json
scripts/render.sh --agent claude \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o settings.json

# Claude Code MDM profile (.mobileconfig) — upload to your MDM
scripts/render.sh --agent claude \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o - \
| scripts/render-plist.sh \
--identifier com.acme.ai-governance.claudecode --organization "Acme Corp" \
--name "Claude Code - Endor AI Governance" \
-o com.anthropic.claudecode.mobileconfig

# Windows config (push via Intune)
scripts/render.sh --agent cursor --target-os windows \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o cursor-hooks.json
```

**Credentials** resolve flag → environment variable → prompt (the prompt only appears on a TTY, so unattended runs never hang):

| Flag | Env fallback | Default |
| --- | --- | --- |
| `--api-key` | `ENDOR_API_CREDENTIALS_KEY` | — (required) |
| `--api-secret` | `ENDOR_API_CREDENTIALS_SECRET` | — (required) |
| `--namespace` | `ENDOR_NAMESPACE` | — (required) |
| `--api-url` | `ENDOR_API` | `https://api.endorlabs.com` |

**`--target-os {macos,linux,windows}`** (default `macos`; macOS and Linux are identical POSIX) chooses the hook form. `windows` inlines the PowerShell bootstrap as a base64 `powershell -NoProfile -EncodedCommand …` so it runs under Git Bash, PowerShell, or cmd alike.

**Behavior settings** go through `--env KEY=VALUE` (repeatable) and land in the right place per tool. Response caching is on by default; monitor-only mode is just `--env ENDOR_AI_AUDIT_NO_BLOCKING=true`.

**`--skip-endorctl-update`** makes the session hook use an already-installed `endorctl` instead of checking for a newer one every session — useful once the fleet is provisioned. It passes through the runner too.

`render.sh` also takes `-o/--output` (`-` for stdout). `render-plist.sh` is agent-agnostic — `--payload-type` (default `com.anthropic.claudecode`) selects the app, and `--identifier`/`--organization` are required, with `--name`, `--profile-identifier`, and the UUID flags optional. Run either script with `--help` for the full list.

### Enforcing vs. monitor-only

- **Enforcing** (default): policy-violating actions get a "block" verdict and the agent halts.
- **Monitor-only** (`--env ENDOR_AI_AUDIT_NO_BLOCKING=true`): every action is still evaluated and recorded, but nothing is blocked.

A good rollout starts in monitor-only, watches the Endor audit log over a representative period to confirm the policies aren't catching false positives, then regenerates without the flag to enforce.

## How it works

**`endorctl` installs and updates itself.** It isn't shipped per tool — the generated session hook runs [`download_endorctl.sh`](scripts/download_endorctl.sh) (or [`download_endorctl.ps1`](scripts/download_endorctl.ps1) on Windows), which installs the binary on first run and refreshes it when a new version ships, verifying a SHA-256 each time. So the only things that change after setup are the config (when you regenerate it) and the governance rules (server-side at Endor, fetched at run time).

**What needs re-delivery when it changes:**

| What changes | How it updates | Your action |
| --- | --- | --- |
| `endorctl` binary | Self-updates on session start (SHA-256 verified); `--skip-endorctl-update` pins it | None |
| Governance rules | Server-side at Endor, fetched at run time | None |
| Claude profile config (macOS) | Regenerate the `.mobileconfig`, re-upload to the MDM | Re-upload |
| Cursor runner config (macOS/Linux) | Runner re-fetches `REF` and re-renders on each scheduled run | None after setup |
| Windows config | Regenerate (`--target-os windows`), re-push via Intune | Re-push |

**Security properties:**

- **Tamper-resistance.** A profile-delivered config (Claude on macOS) is an OS-enforced managed setting — hard for a developer to override. A script-delivered file (Cursor, and the file-based Linux/Windows paths) is not OS-enforced; a determined developer could override it. Cursor has no profile mechanism today, so the profile path is Claude-only.
- **Least-privilege credentials.** A generated profile carries the API key and secret to every laptop — scope it to an **audit-only** credential.
- **Pin the revision.** The runner executes this repo's code as root, so it fetches a specific revision: set `REF` (at the top of `runner.sh`) to a reviewed tag, branch, or commit and each device runs only that, not the moving branch tip. Bump `REF` to roll out a change; the default (`main`) tracks the latest.
- **Credential isolation (Claude).** The `env` block exports into every subprocess Claude spawns, including any `endorctl` the agent itself runs. To keep audit credentials out of the agent's process tree, hook-scoped variables use an `AGENT_HOOK_ENDOR_*` prefix that `endorctl` doesn't read natively, and the hook passes them through as `--api-key …` flags.
- **Robust quoting.** Credentials and `--env` values are escaped for their target — the shell (POSIX, via `@sh`), PowerShell (single-quote doubling), and JSON (`jq`) — and `$VAR` references in the generated commands are quoted, so a value containing a space, quote, `$`, `;`, `*`, or `` ` `` never word-splits or breaks the hook.

## Prerequisites

Each script needs only what's standard to where it runs; the laptop paths stay light, and `plutil` is only the concern of the admin who builds Claude profiles.

| Script | Runs on | Needs |
| --- | --- | --- |
| [`download_endorctl.sh`](scripts/download_endorctl.sh) | developer laptop (inlined into the session hook) | POSIX `sh` + `curl` (plus `awk`/`sed`/`uname`/`mktemp`/`tr` and `sha256sum` or `shasum` — all standard on macOS & Linux) |
| [`download_endorctl.ps1`](scripts/download_endorctl.ps1) | Windows laptop (encoded into the session hook) | Windows PowerShell 5.1 (built in) |
| [`scripts/render.sh`](scripts/render.sh) | admin machine (macOS/Linux, or Windows via Git Bash/WSL), or laptop via the runner | `jq`; for `--target-os windows` also `iconv` + `base64` |
| [`scripts/render-plist.sh`](scripts/render-plist.sh) | admin machine (macOS) | `jq` + `plutil` |
| [`scripts/runner.sh`](scripts/runner.sh) | developer laptop (run by the MDM) | `git` + POSIX `sh` + `jq` |

## Repository layout

```
scripts/
download_endorctl.sh endorctl bootstrap, POSIX (macOS/Linux session hook)
download_endorctl.ps1 endorctl bootstrap, PowerShell (Windows session hook)
render.sh generate a tool's config as JSON
render-plist.sh wrap a config (stdin) into a .mobileconfig profile
runner.sh MDM runner: clone → render → swap-if-changed
examples/ checked-in samples (demo creds, placeholder UUIDs)
docs/ deployment runbooks + the support matrix
```

## Examples

`examples/` holds one checked-in sample per output shape, generated with demo credentials (`PEPE` / `PAPA` / namespace `spiderman`) and placeholder profile UUIDs:

| Shape | Agent | File |
| --- | --- | --- |
| JSON (POSIX hooks) | Claude | `examples/claude/settings.json` |
| MDM profile (plist) | Claude | `examples/claude/com.anthropic.claudecode.mobileconfig` |
| JSON (encoded PowerShell hook) | Claude | `examples/claude/settings.windows.json` |
| JSON (POSIX hooks) | Cursor | `examples/cursor/hooks.json` |
| JSON (encoded PowerShell hook) | Cursor | `examples/cursor/hooks.windows.json` |

There's no separate Linux example: `settings.json` is exactly what Claude reads as the Linux `/etc/claude-code/managed-settings.json` and as the inner payload of the macOS profile, and JumpCloud reuses these same files. Only the Windows samples differ (the encoded `powershell` hook). After changing a script, regenerate the affected examples with the commands above so they stay in sync.

## Extending

To add another agent: add a `build_<agent>` jq builder and a `case` arm in [`scripts/render.sh`](scripts/render.sh), plus a default-`DEST` `case` arm in [`scripts/runner.sh`](scripts/runner.sh) if it's script-delivered.
56 changes: 56 additions & 0 deletions agent-governance/docs/deploy-claude-profile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Deploy Claude Code via an MDM profile (macOS)

On macOS, Claude Code reads managed settings from a Configuration Profile payload of type `com.anthropic.claudecode`, so a profile is **tamper-resistant** — the OS enforces it, and nothing extra runs on the laptop. The one tradeoff: an update means regenerating the profile and re-uploading it.

You'll generate a `.mobileconfig` on an admin Mac (you need `jq` and `plutil`), then upload it through Jamf or Kandji.

## 1. Generate the profile

`render.sh` builds the settings JSON and `render-plist.sh` wraps it into the `.mobileconfig` — pipe one into the other. Use an **audit-only / least-privilege** API credential, since the key and secret are embedded in the profile and delivered to every laptop.

```sh
scripts/render.sh --agent claude \
--api-key "$KEY" --api-secret "$SECRET" --namespace "$NS" -o - \
| scripts/render-plist.sh \
--identifier com.<customer>.ai-governance.claudecode \
--organization "<Customer>" \
--name "Claude Code - Endor AI Governance" \
-o com.anthropic.claudecode.mobileconfig
```

- `--identifier` is a unique reverse-DNS id in your namespace; the inner payload becomes `<identifier>.settings`.
- `--organization` sets the profile's `PayloadOrganization`, and `--name` the MDM-visible display name.
- UUIDs are generated fresh unless you pass `--profile-uuid` / `--content-uuid`.
- `--payload-type` defaults to `com.anthropic.claudecode`, so it's omitted here.
- For an initial monitor-only rollout, add `--env ENDOR_AI_AUDIT_NO_BLOCKING=true` to the `render.sh` step.

Confirm it's a valid property list before uploading (there's a ready-made sample at `examples/claude/com.anthropic.claudecode.mobileconfig`):

```sh
plutil -lint com.anthropic.claudecode.mobileconfig
```

## 2. Upload to the MDM

**Jamf Pro** — Computers → Configuration Profiles → New; add an **Application & Custom Settings → Upload** payload with the `.mobileconfig`; set the **Scope**; Save. Jamf pushes it and the OS installs it as a managed setting. (If you embed it under a Jamf-owned outer profile, set `--profile-identifier` to that profile's id so the outer identity matches.)

**Kandji** — Library → Add New → Custom Profile; upload the `.mobileconfig`; assign it to the target Blueprint.

## 3. Verify

Open Claude Code on a target machine and start a session. The `SessionStart` hook installs/updates `endorctl` and begins reporting to your Endor namespace — confirm the activity in the Endor audit log.

## Updating

Profile-delivered config doesn't auto-update. To ship a change, regenerate the `.mobileconfig` (step 1), re-upload it to the MDM with a **bumped profile version**, and the MDM re-pushes it. How fast it lands depends on the MDM's check-in behavior.

## Linux and Windows: the same settings, as a file

`.mobileconfig` is macOS-only. On Linux and Windows, Claude reads the *same* managed-settings JSON (the `env` + `hooks` that `render.sh --agent claude` produces) as a plain file at a system path — no `render-plist.sh` step, but still admin-enforced and highest-precedence.

| OS | Path | Deliver with |
| --- | --- | --- |
| Linux | `/etc/claude-code/managed-settings.json` (+ `managed-settings.d/`) | [`runner.sh (AGENT=claude)`](deploy-cursor-runner.md), config management, or a [JumpCloud](deploy-jumpcloud.md) Command |
| Windows | `C:\Program Files\ClaudeCode\managed-settings.json` | pre-generate `--target-os windows`, push via [Intune](deploy-windows-intune.md) |

Generate the Linux file with `render.sh --agent claude` (POSIX hooks) and the Windows file with `--target-os windows` (encoded PowerShell hooks).
Loading