Skip to navigation

Policy Information Providers

View as Markdown

A policy information provider supplies facts that neither the runtime nor the request carries: whether a change request is approved, which groups a user belongs to, what a sandbox’s current risk level is. The policy gateway consults the providers registered with it in the Enrichment stage, before any rule runs, and exposes their facts to Cedar as context.<domain>. This page covers the contract, the two provider types, the registration file, and how to write policy over provider facts.

What a Provider Is

A provider attests facts. It never decides.

  • A provider owns one domain, and policy reads its facts as context.<domain>.<claim>. The registration file names the domain context, after the Cedar path it fills. Policies name domains, not providers, so a provider can be replaced without a policy change.
  • A provider returns claims. It never returns decisions, effects, obligations, or provenance. The gateway stamps provenance itself, as context.<domain>._meta.
  • A provider sees only what its type allows. A grpc provider receives the identity and action fields of the subject. An http provider receives only the request fields its lookup keys read. Neither sees the request body, Cedar syntax, or another provider’s output.
  • A provider that cannot answer either fails the request closed or degrades, as its registration declares.

Registration is deployment configuration. Operators register providers in a YAML file passed to apf-policy-gateway with --pip-config. The signed bundle does not carry provider registrations.

Two Provider Types

Every registration declares a type.

typeWhat the gateway doesUse it when
grpcCalls PolicyInformationService.GetAttributes on the provider, over HTTPS with mutual TLS or over a Unix socket with a peer UID check.The system implements the contract, or a small service in front of it does.
httpCalls a JSON API itself: one GET per applicable request. It then selects the record that answers the lookup, checks that it is the record asked for, and keeps the fields policy needs.The facts live in a system with an HTTP API and nothing implements the contract.

A grpc provider connects in one of two ways. With https://host[:port] and a tls block, the provider runs anywhere the gateway can reach, the gateway pins the provider’s CA, and it presents its own client certificate. With unix:///path and an optional peer_uid, the provider runs on the same host and both sides verify the peer’s UID. Plain http:// is accepted only for a loopback host, localhost, 127.0.0.0/8, or ::1, for both types.

The Contract

Providers of type: grpc implement PolicyInformationService.GetAttributes, defined in contracts/proto/nvidia/agent_policy_fabric/pip/v1alpha1/pip.proto in package nvidia.agent_policy_fabric.pip.v1alpha1. The apf-proto crate generates it as pip_v1alpha1. Any language with gRPC support can implement it.

The file’s context is the contract’s domain. The registration file names the namespace after the Cedar path it fills, and the wire contract and audit records carry the same value as domain.

GetAttributesRequest

FieldContents
subjectThe evaluation subject, projected to the fields the provider receives. Refer to Subject Projection.
domainsThe domain the provider is registered for, which is its registration’s context. Every root of the answer must be one of them.
correlation_idThe caller’s correlation id, for audit joins. At most 256 bytes.

EvaluationSubject carries user_subject, tool, capability, action, args, target, runtime, and user_subject_attestation. Fields the provider does not receive are left empty.

GetAttributesResponse

FieldContents
context_attributesOne root per requested domain that has attributes, holding a record of claims, for example {"change": {"approval": "approved"}}. An omitted root means no data for that domain. Never a _meta key.
ttl_secondsRemaining freshness the provider advertises. A NO_DATA answer must carry 0. The gateway does not cache answers in this release, so the value has no other effect.
evidence_refAn opaque pointer to the evidence behind the attributes, such as a change number, for audit. At most 128 characters with no control characters. Never a secret or observation content.
statusATTRIBUTE_STATUS_FOUND or ATTRIBUTE_STATUS_NO_DATA. Must be set.
age_secondsAge of the underlying facts when the response was produced, 0 when fetched live. The gateway records it as _meta.age_seconds.

Field 2, entity_attributes, is reserved for a later version of the contract.

Output Validation

The gateway validates every answer. An answer that fails any rule is recorded as invalid_output.

  • Every root of context_attributes is a requested domain, and its value is a record.
  • No root carries _meta.
  • Claim values hold no nulls. Numbers are integers with an absolute value of at most 2^53 - 1.
  • Nesting is at most 16 levels deep. Keys are 1 to 128 bytes and do not start with __.
  • The encoded response is at most 64 KiB.
  • A NO_DATA answer carries no attributes and a ttl_seconds of 0.
  • A FOUND answer without the domain’s root is treated as no_data.

Failures on the Wire

A provider reports a failure with a gRPC status code, which the gateway records as a status.

gRPC codeRecorded status
DEADLINE_EXCEEDEDtimeout
RESOURCE_EXHAUSTEDcapacity
INVALID_ARGUMENTinvalid_input
UNAUTHENTICATED, PERMISSION_DENIEDunauthorized
UNIMPLEMENTED, FAILED_PRECONDITIONmisconfigured
CANCELLED, which a tonic server reports when the request’s deadline expirestimeout
OUT_OF_RANGE, which the client reports for a reply over the message capinvalid_output
Any other code, including UNAVAILABLE and INTERNALunavailable

The contract evolves additively. The gateway rejects an AttributeStatus value it does not know, so adding one is a breaking change until providers can negotiate the contract version.

The Enrichment Stage

For each evaluation, the gateway runs these steps for every registered provider. Refer to Request Evaluation for where the stage sits in the pipeline.

  1. Applicability. applies_to lists tool names or action ids. The provider runs when an entry equals the request’s tool name or its action id. A registration without applies_to applies to every evaluation. A provider that does not apply makes no lookup and records the status not_applicable.
  2. Projection. The subject is projected to the fields the provider’s type allows. Only those fields leave the gateway.
  3. Lookup. Applicable providers run concurrently, each within its timeout_ms and the whole stage within --pip-stage-budget-ms. At most 32 lookups per provider are in flight at once.
  4. Validation. The answer is checked against the output rules above.
  5. Provenance. The claims are written to context.<domain> with a _meta record. Refer to Provenance.
  6. Failure mode. A provider that failed is handled as its on_failure declares. Refer to Failure Modes.

Action ids differ by surface. On the OpenShell middleware path the tool name and the action id are both the bare MCP tool name, for example github_merge_pull_request. On the EvaluationService path the tool name is resource.class and the action id is <resource.class>::<verb>, for example github::merge_pull_request. One applies_to entry covers both surfaces only when callers use the same tool name on both; otherwise list both forms.

Anything a caller supplies in context_attributes is discarded before the stage runs. Only the gateway writes provider facts.

Registration File

The file is a YAML document with one providers list of 1 to 16 entries. Unknown keys are rejected anywhere in the file, and so are the keys listed under Deferred Keys. The gateway loads the file through its checked startup-file path: it must be a regular file, not a symbolic link, not group or world writable, and at most 64 KiB. The gateway parses and validates the file before any listener binds and refuses to start on any error. A validation error names the provider and the key path, for example provider 'change-management': response.must_match references key 'repo', which request.keys does not define. Once the file is accepted, the gateway logs policy information providers registered with the path, the number of providers, their contexts, and the stage budget.

providers:
- name: change-management
context: change
type: http
applies_to: [github_merge_pull_request]
endpoint:
url: https://itsm.example.com/api/now/table/change_request
auth: { bearer_token_file: /etc/apf/providers/itsm.token }
request:
query: { cmdb_ci.name: "{repository}", state: scheduled }
keys:
repository: { from: args.repository, pattern: "^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}$" }
response:
record_path: result
must_match: { cmdb_ci.name: "{repository}" }
keep: [number, state, approval, risk]
on_failure: degrade
timeout_ms: 200
- name: agent-registry
context: agent
type: grpc
applies_to: [github_merge_pull_request]
endpoint:
url: https://registry.agents.example.com:8443
tls:
ca_file: /etc/apf/providers/registry-ca.pem
client_cert_file: /etc/apf/providers/gateway.pem
client_key_file: /etc/apf/providers/gateway.key
on_failure: fail_closed

Keys

KeyRequiredDefaultMeaning
nameYesThe provider’s label in audit records and deny reasons: 1 to 64 ASCII letters, digits, ., _, or -, unique across the file, and free of any sanitizer word.
contextYesThe domain policy reads the facts under: context: change fills context.change.*. 1 to 32 lowercase letters, digits, or _, starting with a letter, not a reserved root, and free of any sanitizer word. One provider per context. The wire contract and audit records carry it as domain.
typeYeshttp when the gateway calls a JSON API itself, grpc when the system implements PolicyInformationService.
applies_toNoEvery request1 to 64 tool names or action ids, each 1 to 256 characters of ASCII letters, digits, or -_.:/@. The provider runs when an entry equals the request’s tool name or its action id.
endpoint.urlYesWhere the provider is. For http, https:// to any host or http:// to a loopback host, and path segments may hold {key} tokens. For grpc, https://host[:port] with endpoint.tls, unix:///absolute/path, or http:// to a loopback host. Refer to Endpoint Rules.
endpoint.authNoNo credentialhttp only. Exactly one of bearer_token_file and basic_auth_file. A basic credential file holds the user on the first line and the password on the second. The file is read on every call. Refer to Security.
endpoint.tlsWith https://grpc only. ca_file pins the CA that signs the provider’s certificate. client_cert_file and client_key_file are the gateway’s identity. Required with an https:// URL and not allowed otherwise.
endpoint.peer_uidNoThe gateway’s own UIDgrpc only. The UID the provider process runs as. Only allowed with a unix:// URL.
request.queryNoNo queryhttp only. Query parameters sent with the GET. A name is literal. A value is a literal or exactly one {key} token.
request.keysNoNo keyshttp only. 1 to 8 lookup keys by name, each a lowercase letter followed by lowercase letters, digits, or _. A key has from, a subject path, and pattern, an anchored regular expression that starts with ^ and ends with $. Every key is used in endpoint.url or request.query, and every {key} token names a key. Without request, the lookup is fixed and sends no request fields.
response.record_pathNoThe bodyhttp only. A field reference to the record in the answer. Without it, the body is the record. A list, there or as the whole body, must hold exactly one record: an empty list is no_data, a list of several records is invalid_output.
response.must_matchNoNo checkhttp only. Record fields that must equal a key, as <field reference>: "{key}". A record that differs is no_data. Set it whenever the API filters by the key, so the API cannot answer with a different record than the one asked for.
response.keepNoPresence onlyhttp only. The record fields that become claims: a list of field names, each also a valid claim name, or a map of <claim>: <field reference> that renames them. A claim name is a lowercase letter followed by lowercase letters, digits, or _, and is never _meta. Without keep, a found record yields no claims and context.<domain> holds only _meta.
on_failureYesfail_closed or degrade. Refer to Failure Modes.
timeout_msNoThe stage budgetThe lookup’s deadline in milliseconds, from 1 to --pip-stage-budget-ms, 250 by default. For http it is also the HTTP client’s timeout.

Field References

response.record_path, the names in response.must_match, and the field references in response.keep name a place in the JSON answer.

  • A bare name without / is one top-level field. cmdb_ci.name is the field named cmdb_ci.name, not a nested path. The gateway converts it to the JSON pointer /cmdb_ci.name and escapes a ~ in the name as ~0.
  • A value that starts with / is a JSON pointer, used as written. /result/0/approval reaches into nested records and lists.

A field whose name is not a valid claim name, such as cmdb_ci.name, can still become a claim through the map form of keep: keep: { ci: cmdb_ci.name }. A list entry that is not a valid claim name is rejected with a message that points at the map form.

Lookup Keys and Tokens

  • A {key} token is allowed in the path segments of endpoint.url and in request.query values. It is never allowed in the scheme, the host, or a query name.
  • A query value with a token holds exactly one token and nothing else.
  • Every token names a key in request.keys, and every key is used at least once in the URL or the query.
  • Every response.must_match value is exactly one {key} token.
  • A key is read from the subject path its from names. A key that is absent, empty, or does not match its pattern means the source cannot be asked: no request is sent and the lookup fails with the status no_data. The provider’s on_failure then applies: degrade continues with _meta only and degraded: true, fail_closed denies the request as pip_no_data:<provider>. A value is URL-encoded where its token stands; the pattern is the injection guard.

Endpoint Rules

  • A URL must name a host, must not carry a user name or password, and must not carry a fragment.
  • For type: http, endpoint.tls and endpoint.peer_uid are rejected: client certificates for http endpoints are not supported in this release. Register a system that requires one through the contract over mutual TLS.
  • For type: grpc, endpoint.auth, request, and response are rejected.
  • An https:// URL on a grpc provider requires endpoint.tls with all three files. endpoint.peer_uid is not allowed.
  • An http:// URL is accepted only for a loopback host. endpoint.tls and endpoint.peer_uid are not allowed.
  • A unix:// URL is an absolute path with no trailing slash, no . or .. segments, and no ?, #, %, or control characters. endpoint.tls is not allowed. The path must not be the gateway’s own evaluation socket, compared by resolved path and by inode. The socket must already exist when the gateway starts: the gateway inspects it before any listener binds and refuses to start unless it passes the socket rules under Security, so start the provider before the gateway and order the units with After= and Requires= on the provider’s service. An https:// or http provider is contacted only on the first lookup and may be unreachable at startup; on_failure governs lookups, not startup.

Reserved Words

A context can never be one of the roots the request and the engine own: args, annotations, data, body_text, has_tool_name, target_host, target_method, target_path, or target_scheme. The evaluator also refuses an input whose namespace would shadow one of them.

A provider name or context that contains a word the audit sanitizer treats as a forbidden key is rejected, so a provider’s name can always appear in audit records. The words are token, secret, password, credential, private_key, api_key, apikey, refresh_token, access_token, cookie, authorization, body, text, raw, message, and file_content, matched as substrings without regard to case.

Deferred Keys

The following keys are reserved for a later release and are rejected today like any unknown key. The file has no legacy form: a document with a pips root is rejected.

Reserved keyWhat it will configure
cacheReuse of answers between requests. Every evaluation asks the provider in this release, and _meta.cached is always false.
max_in_flightThe concurrent lookup limit per provider. It is fixed at 32.
max_response_bytesThe response size cap for http. It is fixed at 256 KiB.
request.method, request.body, request.headersOther request shapes for http. The gateway sends a GET with Accept: application/json and the credential header.
subjectAn explicit subject projection. A grpc provider receives identity and action, and an http provider receives the fields its keys read.
as in response.keepClaim coercion. Values are kept as the API returns them.
endpoint.tls for type: httpA client certificate toward a JSON API.

Subject Projection

The provider’s type decides which subject fields the gateway may send. The lookup key of an http provider is visible in its registration, so a provider never receives arguments it has no use for.

PathWire fieldContents
user_subjectsubject.user_subject, subject.user_subject_attestationThe user subject the enforcement point asserts. APF does not verify it. The attestation is none when the subject is empty and caller when the enforcement point asserted it.
toolsubject.toolThe tool name.
capabilitysubject.capabilityThe capability, when the request carries one.
actionsubject.actionThe normalized action id: <tool>::<capability>, the tool alone when there is no capability, or invoke when there is neither.
runtimesubject.runtimeThe whole runtime identity: platform, instance_id, agent_type, and attestation, which is caller or openshell-supervisor-jwt. As a lookup key, runtime and runtime.instance_id give the instance id and runtime.platform gives the platform name.
args.<name>subject.args.<name>One named call argument. The name is 1 to 128 characters with no whitespace or control characters. A null or missing argument is left out.
target.host, target.method, target.path, target.schemesubject.target.*One field of the HTTP target each.
  • A grpc provider receives identity and action: user_subject, tool, capability, action, and runtime. Arguments and target fields are never sent to a grpc provider in this release.
  • An http provider receives the distinct from paths of its request.keys, and nothing else. Each value is rendered into the request where its token stands. Any path in the table can be a key’s from; runtime renders as the instance id.

Choose the lookup key with care. A key must be a field the tool acts on or the policy also constrains, such as a repository, a project, or a sandbox identity. It must never be a free argument the agent chooses, or the agent selects which record gates its request.

The two surfaces fill the subject differently.

FieldOpenShell middlewareEvaluationService
user_subjectEmpty, attestation none.OpenShellContext.user_subject, attestation caller.
tool, capabilityThe MCP tool name, no capability.resource.class and verb.
actionThe bare MCP tool name.<resource.class>::<verb>.
argsparams.arguments of the tools/call.The request’s args map, as strings.
runtimePlatform openshell and the sandbox id from the request context. The attestation is openshell-supervisor-jwt in authenticated mode and caller with the insecure transport flag.Platform openshell, the sandbox id, and the agent type from OpenShellContext. The attestation is caller.
correlation_idThe request’s request_id.audit_context["correlation_id"].

The http Provider Type

A type: http registration turns an existing JSON API into a provider. For each applicable evaluation the gateway resolves the keys from the subject, sends one GET, selects the record, checks that it is the record asked for, and keeps the configured fields as the domain’s claims.

endpoint:
url: https://itsm.example.com/api/now/table/change_request
auth: { bearer_token_file: /etc/apf/providers/itsm.token }
request:
query: { cmdb_ci.name: "{repository}", state: scheduled }
keys:
repository: { from: args.repository, pattern: "^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}$" }
response:
record_path: result
must_match: { cmdb_ci.name: "{repository}" }
keep: [number, state, approval, risk]

The Lookup on the Wire

For a github_merge_pull_request call whose repository argument is acme/payments, the gateway sends one request. The key value is percent-encoded for the query.

GET /api/now/table/change_request?cmdb_ci.name=acme%2Fpayments&state=scheduled HTTP/1.1
Host: itsm.example.com
Accept: application/json
Authorization: Bearer <contents of /etc/apf/providers/itsm.token>

The API answers with the matching record under result.

{
"result": [
{
"number": "CHG0012345",
"state": "scheduled",
"approval": "approved",
"risk": "moderate",
"cmdb_ci.name": "acme/payments"
}
]
}

record_path: result selects the list, which holds exactly one record. must_match confirms that its cmdb_ci.name equals the repository key. keep turns four fields into claims, and the rules see this record as context.change.

"change": {
"number": "CHG0012345",
"state": "scheduled",
"approval": "approved",
"risk": "moderate",
"_meta": {
"provider": "change-management",
"status": "found",
"verified": false,
"degraded": false,
"cached": false,
"age_seconds": 0,
"evidence_ref": "CHG0012345"
}
}

Behavior

  • The request is always a GET with Accept: application/json, plus the credential header when endpoint.auth is set. The client follows no redirects and ignores proxy environment variables.
  • Resolved key values are percent-encoded for their position, a path segment or a query value, and never unescaped. A composite argument value, a list or an object, does not resolve. A boolean renders as true or false, and an integer as digits.
  • A key that is absent, empty, or does not match its pattern means the source cannot be asked. No request is sent; the lookup fails with no_data and the provider’s on_failure applies.
  • Without record_path, the body is the record, or a list that holds exactly one record. With it, the same applies to the referenced value. An empty list or a missing reference is no_data; a list of several records is invalid_output, because the answer is ambiguous.
  • Every must_match entry must hold, or the lookup is no_data, so the API cannot answer with a different record than the one asked for.
  • With keep, each named field becomes a claim with the same name, or with the name the map form gives it. A field the record does not carry, or carries as null, is omitted. Values are kept as the API returned them, and a number must be an integer. Without keep, a found record carries no claims and the domain holds only _meta.
  • evidence_ref is the kept number claim, or the kept id claim when there is no number, when it is a string or an integer that meets the evidence rules of the contract. A record kept under other claim names carries no evidence_ref.
  • The lookup must finish within timeout_ms, which is also the HTTP client’s timeout. A response over 256 KiB is unavailable.
  • Credential files are read at call time, so rotation needs no restart, and read once at startup, so a bad credential fails fast instead of degrading every request.

HTTP Status Mapping

ResponseRecorded status
2xx with exactly one matching recordfound
2xx with an empty result or no matching record, or 404no_data
2xx with a list of several recordsinvalid_output
401 or 403misconfigured
Any other non-2xx status, a non-JSON body, a body over 256 KiB, or a claim value that is a floatunavailable

Timeouts

LimitWhereEffect
timeout_msEach registration, default the stage budgetA lookup that does not answer in time is timeout. Between 1 and the stage budget.
--pip-stage-budget-msThe gateway, default 250Hard cap on the whole stage, and the default and upper bound of every timeout_ms. A lookup still in flight when it expires is timeout for that provider. A registration whose timeout_ms exceeds it is rejected at startup.
In-flight limitEach provider, fixed at 32Concurrent lookups to one provider. A lookup beyond it is capacity.

Keep the stage budget below the timeout OpenShell applies to middleware calls. The middleware advertises no binding timeout of its own and inherits the deadline OpenShell’s operator configured, so a stage that outlives that deadline makes OpenShell treat the call as failed. For a provider off the host, prefer degrade and a short timeout_ms.

Provenance

The gateway writes context.<domain>._meta beside the claims.

FieldContents
providerThe registered provider name. For audit; policies name domains, not providers.
verifiedtrue when a grpc provider’s peer was authenticated, by its mutual TLS identity or its registered UID, and the output passed validation. An http provider, and a grpc provider reached over plain http://, are never verified. It does not mean the claim was independently proven.
age_secondsThe fact age the provider reported. 0 for an http provider.
degradedtrue when the provider failed and on_failure: degrade let the evaluation continue without its claims.
cachedAlways false in this release. Every evaluation asks the provider.
statusThe lookup status. Refer to Statuses.
evidence_refThe provider’s evidence pointer, when it supplied one. For an http provider, the kept number or id claim.

When the lookup found claims, the record holds the claims and _meta. When it did not, whether the provider did not apply, holds nothing for the subject, or degraded, the record holds only _meta.

Audit Extension

Every authorization record written after the stage ran carries an extensions.x_apf_pip object with one entry per provider.

"extensions": {
"x_apf_pip": {
"providers": [
{
"domain": "change",
"provider": "change-management",
"status": "found",
"degraded": false,
"cached": false,
"age_seconds": 0,
"evidence_ref": "CHG0012345",
"latency_ms": 12
}
]
}
}

domain is the registration’s context. Domains and provider names are values, not keys, so the audit sanitizer’s key rules cannot demote the record. An evidence_ref the sanitizer rejects is dropped from the entry, not the entry from the record. latency_ms is 0 for a provider that did not apply. Both the middleware’s apf-policy-gateway-middleware-audit.jsonl and the gateway’s apf-policy-gateway-audit.jsonl carry the extension. Refer to Audit Event Schema.

Failure Modes

on_failureWhen the provider cannot answer
fail_closedThe request is denied before any rule runs, with the reason pip_<status>:<provider>, for example pip_timeout:identity. On the OpenShell path the denial carries the reason code policy_denied. From EvaluationService it is a DENY with that reason. The engine never sees the request.
degradecontext.<domain> holds only _meta, with degraded: true and status naming the failure, and evaluation continues.

Two statuses deny regardless of the failure mode. invalid_input is a failure the caller caused, so a caller cannot induce degradation: a projected subject over the 64 KiB message cap, a correlation id over 256 bytes, or a provider answering INVALID_ARGUMENT, which reports the subject it received as malformed. internal is a failure of the stage itself, such as a lookup task that panicked, and denies with pip_internal:<provider>. A provider reserves INVALID_ARGUMENT for a malformed subject; a subject it does not hold is NO_DATA.

Statuses

StatusMeaningFailure
foundClaims were supplied, or an http provider without keep found its record.No
no_dataThe provider holds nothing for the subject (not a failure), or a key was absent or rejected so the source could not be asked (a failure: degraded is true and on_failure applies).Depends
not_applicableNo applies_to entry matched. No lookup was made.No
timeoutNo answer within timeout_ms, or the stage budget expired first.Yes
unavailableThe provider could not be reached, or answered with an unmapped gRPC code or HTTP status.Yes
capacityThe provider’s in-flight limit of 32 lookups was reached.Yes
invalid_outputThe answer failed validation.Yes
invalid_inputThe subject could not be sent, or the provider rejected it with INVALID_ARGUMENT. Caller fault, and denies in both modes.Yes
unauthorizedThe provider refused the gateway’s identity.Yes
misconfiguredThe provider is misconfigured, such as a rejected credential or an unimplemented method.Yes
internalThe Enrichment stage itself failed, for example a lookup task panicked. Denies in both modes.Yes

Writing Policy over Provider Facts

  • Guard a permit with has. context.change has approval && context.change.approval == "approved" grants only when the fact is present. A degraded or empty record keeps the grant off, and the tool call is denied with no grant for <action>.
  • A forbid that reads a provider fact without a guard denies the request when the fact is missing, because a forbid that fails to evaluate denies. That is the safe default for a deny rule. To deny explicitly when a provider degraded, test context.<domain>._meta.degraded.
  • degrade relies on Cedar’s missing-attribute behavior. The idiom that ports to any engine is an explicit _meta.degraded test, or fail_closed.

Refer to Capability Grants.

Security

  • Sockets. Both ends of a Unix socket verify the peer. The gateway connects only to a socket owned by peer_uid that other users cannot access and that the gateway’s user can write: owner-only (0600) when peer_uid is the gateway’s own UID, otherwise group-writable (0660) with a group the gateway belongs to, since connecting to a Unix socket needs write permission on it. The gateway checks at startup that it can connect. Ancestors must be owned by root, the gateway’s user, or peer_uid and not writable by others; a root-owned directory with the sticky bit set, such as /tmp, is also accepted. The hardened systemd unit sets PrivateUsers=yes, which maps every UID other than root and the service user to nobody, so a provider running as another user needs a unit without that setting or a shared group. A provider must accept connections only from the gateway’s UID and must reject bearer credentials on the socket.
  • TLS material. ca_file and client_cert_file must be regular files that are not group or world writable. client_key_file must be readable by its owner only.
  • Credentials. Bearer tokens and basic credentials come from files, never from the registration file or the URL. A credential file must be a regular file readable by its owner only and at most 8 KiB. A URL that carries a user name or password is rejected.
  • The registration file. It is loaded through the gateway’s checked startup path: a regular file, not a symbolic link, not group or world writable, and at most 64 KiB.
  • Network egress. The hardened unit in deploy/systemd/apf-policy-gateway.service sets IPAddressAllow=localhost and IPAddressDeny=any. A provider off the host needs an IPAddressAllow= entry for its address in a drop-in, and an IPv6 address also needs AF_INET6 in RestrictAddressFamilies.
  • No forgery. A caller’s context_attributes are discarded before the stage runs. A provider’s _meta is rejected as invalid output. Only the gateway writes provenance.
  • Lookup keys. Key each lookup on a field the tool acts on or the policy constrains, never on a free argument the agent chooses.

Worked Example

A change-management system holds the change requests for each repository, as its configuration item. A grant lets an agent merge a pull request into a repository only while an approved, scheduled change exists for it.

Register the system as a provider. Every evaluation asks the system, so a withdrawn approval takes effect on the next request.

providers:
- name: change-management
context: change
type: http
applies_to: [github_merge_pull_request]
endpoint:
url: https://itsm.example.com/api/now/table/change_request
auth: { bearer_token_file: /etc/apf/providers/itsm.token }
request:
query: { cmdb_ci.name: "{repository}", state: scheduled }
keys:
repository: { from: args.repository, pattern: "^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}$" }
response:
record_path: result
must_match: { cmdb_ci.name: "{repository}" }
keep: [number, state, approval, risk]
on_failure: degrade
timeout_ms: 200

Start the gateway with --pip-config /etc/apf/providers.yaml, or set APF_PIP_CONFIG. Then add a capability rule to the bundle. The action is the bare MCP tool name because the OpenShell middleware evaluates the call.

apiVersion: nvidia.agent-policy-fabric.policy/v1alpha1
kind: PolicyRule
metadata:
name: change-grants
spec:
language: cedar
role: capability
definition: |
@id("change-grants/github_merge_pull_request")
permit (
principal,
action == APF::Action::"github_merge_pull_request",
resource
)
when {
context.change has approval &&
context.change.approval == "approved"
};
SituationResult
An approved, scheduled change exists for the repository in args.repository.The lookup is found and the grant fires. The audit record names the provider and carries the change number as evidence_ref.
The approval is withdrawn.The next request is denied with no grant for github_merge_pull_request. No rule or bundle changed.
No change exists for the repository.The lookup is no_data. context.change holds only _meta, and the has guard keeps the grant off.
The system is unreachable, with on_failure: degrade.context.change._meta.degraded is true, and the guard denies the call.
The system is unreachable, with on_failure: fail_closed.The request is denied before any rule runs, with pip_unavailable:change-management.

The lookup is keyed on the repository the tool acts on, so the agent cannot name a different, approved change to satisfy the rule. The same merge performed as a raw HTTP request is governed by the sandbox network policy, not by this rule.

Identity Facts

The same shape turns an identity system into a provider. Key the lookup on user_subject, keep the fields a rule needs, and fail closed so a request that cannot be looked up is denied before any rule runs. The JSON API here answers a username query with a list of matching users. Leave record_path out: the gateway requires the list to hold exactly one record, where a pointer such as /0 would take the first of several.

- name: identity
context: user
type: http
applies_to: [github_repo_write]
endpoint:
url: https://idp.example.com/api/users
auth: { bearer_token_file: /etc/apf/providers/idp.token }
request:
query: { username: "{user}" }
keys:
user: { from: user_subject, pattern: "^[a-z0-9][a-z0-9._@-]{0,127}$" }
response:
must_match: { username: "{user}" }
keep: [clearance, department]
on_failure: fail_closed
timeout_ms: 200

A grant then reads the facts: context.user has clearance && context.user.clearance == "elevated". The subject is the one the caller asserts on EvaluationService.Evaluate; the OpenShell middleware path carries none, which is why the example is scoped with applies_to: unscoped, it would deny every sandbox request as pip_no_data:identity. Refer to Identity and Delegation.

The repository ships the three registration shapes as annotated files under examples/pips/.

Next Steps