---
title: "Enterprise deployment"
---

# Enterprise deployment

Roll the CLI out to an organisation with a managed settings file, a signed remote policy, the organisation policy from Govern, an audit log, OpenTelemetry export and network controls.

Administrators control the CLI through **managed layers**, which sit above every user and project setting:

| Managed layer | Where it comes from | Works today |
|---|---|---|
| Managed settings file | A file on each machine | Yes |
| Remote policy | A policy document served over HTTPS, named by the managed file | Yes |
| Organisation policy | Govern, after the person signs in | Not yet: the current Govern server does not serve the policy route. See [Sign in to Govern](https://neuralinverse.com/docs/cli/sign-in/#known-gaps) |
| Governance policy bundle | Managed settings, the shared governance folder or the repository | Yes. See [Permissions and modes](https://neuralinverse.com/docs/cli/permissions/#organisation-governance-comes-first) |

## Managed settings file

| OS | Path |
|---|---|
| Linux | `/etc/neuralinverse/managed-settings.json` |
| macOS | `/Library/Application Support/NeuralInverse/managed-settings.json` |
| Windows | `%ProgramData%\NeuralInverse\managed-settings.json` |

`NEURALINVERSE_MANAGED_SETTINGS` names another path.

The file uses the normal settings format. Every top-level key it sets replaces the user's value and is locked. For `permissions`, each field is locked on its own: the policy's `deny` and `ask` lists are **combined** with users' lists (users can add restrictions, never remove them), while other fields, such as `allow`, `mode` or `disableFullAuto`, replace the user's value. Set `"allow": []` to forbid personal allow rules entirely.

```jsonc
{
  "providers": {
    "corp": { "kind": "openai", "baseUrl": "https://llm-gateway.example.com/v1", "apiKeyCommand": "corp-llm-token" }
  },
  "model": "corp/gpt-5",
  "permissions": {
    "disableFullAuto": true,
    "deny": ["read_file(.env)", "read_file(**/*.pem)", "run_shell(curl *)", "run_shell(wget *)", "web_fetch"]
  },
  "sandbox": { "enabled": true, "required": true, "allowNetwork": false },
  "audit": { "enabled": true },
  "hooks": { "tool.before": [{ "matcher": "run_shell", "command": "/opt/corp/ni-command-policy" }] }
}
```

`neuralinverse config validate` shows which keys a policy locks.

Settings honoured only in a managed layer include `keystone.clientId`, `keystone.policyKeys`, `keystone.requireSignIn`, `governance.*`, `toolEnvironment`, `workspaceBindingPolicy`, `activity.required`, `activity.content`, several `checks.*` keys and `embed.*`. Each is described on its own page.

## Remote policy

Instead of distributing the whole policy to every machine, the managed file can point at a central policy document served over HTTPS:

```jsonc
// /etc/neuralinverse/managed-settings.json
{
  "remotePolicyUrl": "https://policy.example.com/neuralinverse/policy.json",
  "remotePolicyRequired": true,              // refuse to start without a policy (fail closed)
  "remotePolicyPublicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA…\n-----END PUBLIC KEY-----",
  "remotePolicyTokenCommand": "corp-sso-token --audience ni-policy"   // optional bearer token
}
```

- The URL can also come from `NEURALINVERSE_MANAGED_SETTINGS_URL` when the managed file sets none; the managed file always wins. Only `https://` is accepted (plain `http://` only for loopback hosts, such as a local mirror). Redirects are not followed.
- The document uses the normal settings format and is validated against the settings schema. It is applied at the managed layer, above the local managed file, with the same locking rules. It cannot set the `remotePolicy*` keys itself.
- A bearer token is sent when `NEURALINVERSE_MANAGED_SETTINGS_TOKEN` is set or `remotePolicyTokenCommand` prints one.
- The policy is fetched at start-up (5-second timeout, at most 1 MB) with `If-None-Match`, and the last good copy is kept in `~/.neuralinverse/managed/remote-policy.json`. If the server cannot be reached, the cached copy is used with a warning; with `remotePolicyRequired` and no cached copy, the CLI exits with an error.
- Long sessions refresh the policy every 60 minutes. When it changes, a notice is shown; new `deny` rules take effect at once, everything else at the next start.
- **Signatures.** With `remotePolicyPublicKey` (an Ed25519 key as PEM, base64 SPKI DER, or base64 of the raw 32-byte key), the server must also publish `<url>.sig`: the base64 Ed25519 signature of the exact policy bytes. Unsigned or wrongly signed policies are rejected, and the cached copy is verified again on every start, so editing it locally cannot loosen the policy.

Sign a policy with OpenSSL:

```bash
openssl genpkey -algorithm ed25519 -out policy-signing.pem
openssl pkey -in policy-signing.pem -pubout -out policy-signing.pub   # goes into remotePolicyPublicKey
openssl pkeyutl -sign -inkey policy-signing.pem -rawin -in policy.json | base64 > policy.json.sig
```

## Signed organisation policy

When a person signs in to Govern, the CLI fetches the organisation's policy from `GET <api>/api/v1/policy/bundle`, verifies it with the keys pinned in `keystone.policyKeys`, and applies it at the managed layer. The current Govern server does not serve this route yet, so this section describes the CLI's side of the contract. See [Sign in to Govern](https://neuralinverse.com/docs/cli/sign-in/#known-gaps).

Two forms are accepted, both signed with Ed25519 by a pinned key:

1. **The signed bundle envelope** shared with the Editor's checks: `{ "payload": { "formatVersion": 1, "orgId", "version", "issuedAt", "expiresAt", "packs": [], "waivers": [], ...CLI fields }, "signature": { "algorithm": "Ed25519", "keyId", "value" } }`, signed over the canonical JSON of the payload.
2. **A bare bundle with a detached signature** over its exact bytes, in the `X-Keystone-Signature: <keyId>.<base64>` header or at `<url>.sig`.

The CLI fields:

```jsonc
{
  "orgId": "org_123",
  "orgName": "Acme",                       // optional, shown in the model picker and header
  "version": 42,                           // never lower than a version already accepted
  "issuedAt": "2026-10-01T00:00:00Z",
  "expiresAt": "2026-11-01T00:00:00Z",
  "offlineGraceHours": 72,
  "keyId": "ks-2026",                      // bare form only
  "settings": { "permissions": { "deny": ["run_shell(curl *)"] }, "sandbox": { "enabled": true } },
  "models": {
    "allowed": ["keystone/claude-sonnet-5-5", "keystone/gpt-5", "ollama/*"],
    "personalModels": false,
    "gateway": { "baseUrl": "https://models.acme.example/v1", "kind": "openai" }
  },
  "content": {
    "skills":   { "release": "---\nname: release\ndescription: ...\n---\n..." },
    "agents":   { "security-reviewer": "---\nname: security-reviewer\n---\n..." },
    "commands": { "ship": "---\ndescription: Ship a release\n---\n..." },
    "rules":    { "deny": ["web_fetch(domain:pastebin.com)"], "ask": [], "allow": ["read_file"] }
  }
}
```

A bundle is rejected as a whole when its signature does not verify with a pinned key, it has expired, its `orgId` differs from the signed-in account's organisation, or its version is older than one already accepted (or the same version with different content).

| Part | Effect |
|---|---|
| `settings` | Applied at the managed layer like managed settings: they win over user and project settings and lock the keys they set. `keystone` and the `remotePolicy*` keys are ignored |
| `models.allowed` | Filters the model picker and `neuralinverse models`; any other model is refused in `/model`, `--model`, routing and the fallback chain. Entries are `provider/model` refs, bare model ids or globs with `*` |
| `models.gateway` | Adds the provider `keystone` (OpenAI- or Anthropic-shaped), called with the person's Govern access token. OpenAI shape: requests go to `<baseUrl>/chat/completions`; Anthropic shape: `<baseUrl>/v1/messages` |
| `models.personalModels: false` | Disables every user-configured provider |
| `content` | Written to a managed, read-only folder (`~/.neuralinverse/managed/keystone/content`) and loaded with organisation scope: organisation skills, agent profiles and commands win over project and user files of the same name. Organisation `deny` and `ask` rules are added at the managed layer; organisation `allow` rules are added to the user's allow list |

Offline behaviour and the locked state are described in [Sign in to Govern](https://neuralinverse.com/docs/cli/sign-in/#offline-use).

## Audit log

With `"audit": { "enabled": true }` (or `NEURALINVERSE_AUDIT_LOG=<path>`), every session writes an append-only JSON Lines trail, by default `~/.neuralinverse/audit/<UTC date>.jsonl` with mode 0600. `audit.path` sets another location.

| Event | Content |
|---|---|
| `session_start`, `session_end` | User, host, working folder, version, model, mode |
| `permission` | Tool, target (path, command or URL), decision, and whether a rule, the mode, a hook or the user decided |
| `tool` | Tool, summary, duration, success, and a SHA-256 of the full input. Commands, paths, URLs and queries are recorded; file contents are not |
| `mode_change`, `rule_added`, `hook_blocked`, `mcp_connect` | As named |
| `checks_gate` | Each [checks](https://neuralinverse.com/docs/cli/checks/) gate run: fingerprints, rule ids and severities, never code or messages |
| `keystone_policy` | Changes of the organisation policy state between `online`, `cached` and `locked` |

Ship these files to your SIEM with your usual log forwarder. The audit log is local and does not depend on Govern; the [activity record](https://neuralinverse.com/docs/cli/activity/) is a separate feature that sends events to Govern.

## OpenTelemetry

Telemetry is **off by default** and has no built-in destination. To send traces, metrics and logs to your own collector (OTLP over HTTP with JSON encoding):

```jsonc
{
  "telemetry": {
    "enabled": true,
    "endpoint": "https://otel-collector.example.com:4318",
    "headers": { "Authorization": "Bearer ${OTEL_TOKEN}" },
    "resourceAttributes": { "team": "payments" },
    "includePrompts": false
  }
}
```

`NEURALINVERSE_TELEMETRY=1` turns it on, and `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, the per-signal `_ENDPOINT` and `_HEADERS` variables, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME` and `OTEL_SDK_DISABLED=true` are honoured.

| Signal | Content |
|---|---|
| Traces | `ni.prompt` per prompt, with `ni.llm.turn` (model, tokens, stop reason) and `ni.tool` (tool, duration, outcome) children |
| Metrics | `ni.session.count`, `ni.prompt.count`, `ni.tokens`, `ni.cost.usd`, `ni.tool.calls`, `ni.tool.duration`, `ni.permission.denials`, `ni.retries`, `ni.compactions`, `ni.lines.changed` |
| Logs | Prompt submitted (text only with `includePrompts`, secrets redacted), permission decisions, errors |

Tool inputs and outputs are never exported.

## Network and credentials

- Route all model traffic through an internal gateway with a custom `openai`, `anthropic` or `bedrock` provider and `baseUrl`, and lock `providers` and `model`.
- Keep keys out of files with `apiKeyCommand` (any secrets-manager command) or `apiKeyEnv`.
- `HTTPS_PROXY`, `NO_PROXY` and `NODE_EXTRA_CA_CERTS` are honoured.
- Nothing is sent anywhere except the model providers, MCP servers, your Govern deployment when people sign in, and an OpenTelemetry collector if you configure one. The only other request is the daily npm registry version check, off with `NEURALINVERSE_DISABLE_UPDATE_CHECK=1` and in CI.
- For a self-hosted or air-gapped Govern deployment, set `keystone.baseUrl` in managed settings. To install without internet access, mirror the release assets and set `NI_DOWNLOAD_BASE` ([Install and update](https://neuralinverse.com/docs/cli/install/#install-from-an-internal-mirror)).

## Recommended baseline

1. A managed settings file with `disableFullAuto`, deny rules for secrets, and `audit.enabled`.
2. `sandbox.enabled` and `sandbox.required` on Linux and macOS workstations.
3. A `tool.before` hook for organisation-specific command policy.
4. A shared `.neuralinverse/` folder in each repository with `AGENTS.md`, commands, and allow rules for the project's own test and lint commands.
5. If you use Govern, `keystone.baseUrl` and, when people must be signed in, `keystone.requireSignIn`.

For Govern's own deployment, see [Deploy and operate](https://neuralinverse.com/docs/deploy/).
