Policies#
A policy is what an agent is allowed to do. It is a YAML or JSON document, created and updated like any other resource, and it is the single place the answer lives.
Default posture is deny. A sandbox with no policy can execute code and reach nothing.
Create a policy#
# agent-base.yaml
name: agent-base
description: Baseline egress and inference for production agents
networkDefaultVerdict: deny
networkDefaultTransport: upstream
allowedDomains:
- pattern: api.github.com
verdict: allow
transport: upstream
scheme: https
description: GitHub API
- pattern: registry.npmjs.org
verdict: allow
transport: direct
description: Package downloads
credentials:
- credentialName: github-token
envVarKey: GITHUB_TOKEN
managedInference:
enabled: true
provider: bedrock
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/policies \
-d @agent-base.json
Create a policy called agent-base in the production project.
Deny by default. Allow api.github.com over upstream transport and
registry.npmjs.org direct. Attach the github-token credential as
GITHUB_TOKEN, and enable managed inference on bedrock.
nexusctl policy create --project production -f agent-base.yaml
-f - reads from stdin, which is what makes the edit loop work:
nexusctl policy get agent-base -o yaml > policy.yaml
$EDITOR policy.yaml
nexusctl policy update agent-base -f policy.yaml
Update replaces top-level fields
Each top-level field you supply replaces the existing value. allowedDomains is list-replace, not list-merge. Fields you omit are left alone, so the get-edit-update loop round-trips cleanly. Pass an explicit allowedDomains: [] to clear a list.
A sandbox's embedded policy#
A sandbox can carry its own policy inline instead of attaching a named one. That document is updated as part of the sandbox, and it merges rather than replaces:
curl -fsS -X PATCH \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/sandboxes/$SANDBOX_ID \
-d '{"policy": {"name": "nightly-refactor-policy", "allowedDomains": []}}'
| You send | Result |
|---|---|
| A key with a value | Replaces the stored value whole. allowedDomains: [] clears the list. |
| A key omitted | Keeps its stored value. |
piiMasking: null or managedInference: null |
Clears that block. |
policy: null |
Drops the embedded policy entirely. |
No policy key |
Leaves the policy untouched. |
name is required on every update and is always written, so a partial update that mistypes it renames the policy rather than failing. description takes no null and cannot be cleared.
The practical effect is that two administrators editing different parts of one sandbox's policy no longer overwrite each other's work. It narrows that window rather than closing it: the granularity is the key, not the list entry, so editing one domain still sends the whole allowedDomains list.
Standalone project and org policies keep replace semantics — the table above applies to a sandbox's embedded policy only.
The policy document#
| Field | Purpose |
|---|---|
name |
Required. Unique within its scope. |
description |
Free text. |
networkDefaultVerdict |
allow or deny for unmatched domains. Use deny. |
networkDefaultTransport |
upstream or direct for unmatched domains when the default verdict allows. |
allowedDomains |
Ordered domain rules. First match wins. |
credentials |
Credentials to attach, by name. |
connectors |
Connector grants with explicit tool allowlists. |
integrations |
Clusters and AWS connections to enable. |
managedInference |
Opt in to platform-proxied model access. |
piiMasking |
PII masking for model calls. |
env |
Environment variables for sandboxes using this policy. |
Managed inference#
managedInference:
enabled: true
provider: openrouter
env:
ANTHROPIC_MODEL: google/gemini-3-pro
provider names one of bedrock, azure, bedrock-mantle, openai, or openrouter, and must be one the install has configured — anything else has no proxy route behind it and returns 404. Absent defaults to bedrock.
The grant is to the backend, not to a model: choosing a model is the agent's job, per request. Where you do want a project pinned to one, set ANTHROPIC_MODEL in env as above, which is where every other agent-facing variable already lives. A value the policy sets always beats the platform's own seed.
Domain rules#
allowedDomains:
- pattern: "*.internal.example.com"
verdict: allow
transport: upstream
scheme: https
rules:
- method: GET
path: /v1/reports/*
- method: POST
path: /v1/reports/export
Wildcards are supported in pattern. rules narrow a domain to specific methods and path globs, which is how you express read-only access to an API rather than approximate it.
transport: upstream routes the call through the platform so credentials can be injected and the request fully audited. transport: direct allows it without interception — appropriate for high-volume, low-risk traffic such as package registries.
A domain entry carries two further fences: binaries, which scopes it to named callers, and graphql on a rule, which scopes it to named operations. Both are described below.
Fencing a domain to specific callers#
binaries restricts an entry to connections opened by the programs you name. Everything else reaching that host is denied.
allowedDomains:
- pattern: github.com
verdict: allow
transport: direct
binaries:
- /usr/bin/git
description: Clone and fetch only — git, not the agent's own HTTP client
The sandbox compares each entry to the kernel-resolved /proc/<pid>/exe of the connecting process, byte for byte. That has three consequences worth knowing before you write one:
- Paths must be absolute. A relative path can never match what the kernel resolves, so the platform refuses one rather than accept a condition that is dead on arrival. Leading or trailing whitespace is refused for the same reason.
- There is no glob.
/usr/bin/gitmatches that path and nothing else. Name each binary you mean. - The fence scopes the whole entry. A caller not on the list is denied outright rather than falling through to a later rule.
binaries is only meaningful on verdict: allow, and the platform rejects it on a deny. A deny already blocks every caller; a deny fenced to two of them would have to mean "every caller except these", which nothing downstream can express — so an org ceiling could not compose it without admitting the very caller the org blocked. To block a host for everyone, deny the host. To block one program, fence the allow entry that grants it.
An empty list is rejected. Omit the key to match any caller.
Fencing a GraphQL endpoint to specific operations#
A REST fence works on method and path, which a GraphQL endpoint does not offer — everything is POST /graphql. A graphql block on a rule reads the operation instead, so one endpoint can be narrowed the way a REST API can.
allowedDomains:
- pattern: api.github.com
verdict: allow
transport: upstream
rules:
- path: /graphql
graphql:
operationType: query
operationName: "Get*"
fields:
- viewer
- repository
| Field | Purpose |
|---|---|
operationType |
Required: query, mutation, subscription, or * for any. subscription and * are what grant a WebSocket upgrade. |
operationName |
Glob the document's declared operation name must match. Omit to cover any name, including an unnamed operation. |
fields |
Globs bounding the root fields the operation may select. |
fields bounds the selection rather than picking part of it: every root field the operation selects must match one of the entries, so an operation asking for one permitted field and one unlisted field is denied whole. Naming fields also denies schema introspection through __schema. An empty list is rejected — omit the key to permit any field.
Two behaviours are worth internalizing before you rely on this:
graphqlcomposes withmethodandpath. All three conditions apply together, so the example above covers a/graphqlpath and a matching query. A rule must carry at least one of the three.- A GraphQL fence closes the endpoint to non-GraphQL rules. Once any rule with a
graphqlblock matches a request, only agraphqlrule can admit it. A plainmethod/pathrule on the same host cannot become a back door around the operation fence.
A misspelled key is rejected, not ignored
Every fence object is validated strictly. grapql or operationNme fails the write rather than being dropped, because dropping it would leave a rule that reads as a broad grant — a bare method and path admitting any body. A narrowing has to fail loudly or hold, so the sandbox refuses a policy it cannot parse and falls back to deny.
Connectors#
connectors:
- connectorId: 123e4567-e89b-12d3-a456-426614174000
allowedTools:
- jira__search_issues
- jira__get_issue
credentialId: 223e4567-e89b-12d3-a456-426614174000
allowedTools is required and has no wildcard. An empty array denies every tool in that reference. Tools an upstream adds later are not granted until you name them.
Integrations#
integrations:
- type: kubernetes
name: prod-eu
- type: aws-connection
name: production-aws
PII masking#
piiMasking:
types: [EMAIL, PHONE, CREDIT_CARD, PERSON]
unmaskResponses: true
failOpen: false
failOpen: false is the default and the compliance-safe choice: if masking fails, the request does not go out. See Privacy and PII controls for the full type list.
Scope and resolution#
Policies live at two levels.
| Scope | Created with | Role |
|---|---|---|
| Organization | --org |
Reusable across projects, and a ceiling on what projects may grant. |
| Project | --project |
Specific to one project's work. |
An agent's effective policy is the merge of every policy that reaches it, clipped by the org ceiling. A project binding that grants more than the org policy permits is clipped, not honoured.
A ceiling only ever narrows#
The two fences compose the same way, and in one direction only: a request must clear the ceiling's condition and the project's.
| Ceiling says | Project says | Result |
|---|---|---|
| No fence | binaries: [/usr/bin/git] |
The project's fence stands. |
binaries: [/usr/bin/git] |
No fence | The ceiling's fence stands. |
binaries: [/usr/bin/git, /usr/bin/curl] |
binaries: [/usr/bin/curl] |
/usr/bin/curl — the callers they share. |
binaries: [/usr/bin/git] |
binaries: [/usr/bin/curl] |
Nothing passes. The lists name no caller in common. |
GraphQL fences intersect on the same principle: the operation types, name globs, and field lists are conjoined, and two conditions with no overlap close the entry rather than open it. A project cannot widen a fence the org wrote by adding a caller or an operation of its own, and it cannot remove one by omitting it.
Find the cases where a project asked for more than it got, before they surprise someone:
nexusctl policy-binding list-drift --org acme
Read what actually applies to a running sandbox:
curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/projects/$PROJECT_ID/sandboxes/$SANDBOX_ID/effective-policy
Reaching agents#
Policies do not apply themselves.
- Sandboxes attach policies directly — they are their own principal, so there is nothing to bind. See Sandboxes.
- Users and API tokens get policies through a policy binding.
Inspecting#
curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/projects/$PROJECT_ID/policies
What does the agent-base policy in production actually permit?
Which sandboxes and bindings use it?
nexusctl policy list --project production
nexusctl policy describe agent-base --project production
describe renders allowed domains, connectors, credentials, integrations, PII masking, and current bindings as one view — the fastest way to answer "what does this policy actually permit".
Reading fences from the CLI#
Every allowed-domain row carries every fence on the entry, so a domain scoped to one binary or one GraphQL operation is visibly scoped rather than reading like an unconditional grant.
nexusctl policy describe agent-base --project production
Each row reads: the pattern, the verdict, scheme/transport, then callers= and rules=. Rules are compact — METHOD path for an ordinary rule, graphql:<operationType> path followed by the name and fields a GraphQL rule restricts — and several rules on one domain are separated by ;.
| Cell | Prints |
|---|---|
scheme/transport |
* where the entry names no scheme. On a deny the transport is -, because the sandbox ignores transport on a deny and printing the stored value would read as a route that applies. |
callers= |
The fenced binaries, comma-separated. any where the key is absent, none where it is present but empty. |
rules= |
The rule summaries. any where there is no rule fence. |
policy get is the place to read the raw fields.
any and none are not interchangeable
any means the policy omits the key, so nothing is conditioned on that axis. none means it carries the key but leaves it empty — and on a caller fence the sandbox reads that as nothing passes. One placeholder for both would invert the reading of an empty caller list.
An empty rule list is the exception and prints any: with no rule to fail a request against, every request passes. The API rejects an empty list on both fences, so only a hand-edited policy reaches either state — and that is precisely the policy an auditor most needs shown truthfully.
Starting policy set#
| Policy | Scope | Contents |
|---|---|---|
org-ceiling |
Org | Everything the organization permits at all. Deny by default; no credentials. |
agent-base |
Org | Managed inference, package registries, documentation sites. |
<system>-readonly |
Project | One system, GET only, with its credential. |
<system>-write |
Project | The same system with write methods, bound to fewer subjects. |
Splitting read from write as separate policies makes a privilege grant a visible, reviewable change rather than a line edit inside a larger document.
Related#
- Policy bindings — attaching policies to users and tokens
- Credentials — the secrets policies attach
- Connections — the systems policies grant
- Audit trail — every allow and every deny