SAW gateways call one governance service, governance-interceptor.openshell-agents.svc:18081.
By default that is the OpenShell governance interceptor (governance-interceptor.md).
One switch replaces it with NVIDIA's Agent Policy Fabric (APF), which serves the same
policy from a signed bundle: each file is hashed, the manifest is signed with Ed25519 and
carries a policy_revision that must go up with every change (see Rollback). APF also stamps provenance on every
sandbox (apf.nvidia.com/* annotations), refuses policy changes that do not come through a
bundle, and writes a JSONL audit trail.
interceptor (default) |
apf |
|
|---|---|---|
| Serves | governance-policy ConfigMaps |
signed bundle built from the same files |
| Images | ghcr.io/validatedpatterns-sandbox/governance-interceptor (public) |
ghcr.io/mkhaas/apf/*:0.1.0-driverconfig.3 (private for now, public later; amd64 and arm64; chart 0.2.2-driverconfig) |
| Policy change | edit charts/governance-policy, push |
edit, re-sign (make apf-bundle), push |
Runtime openshell policy update |
validated | refused: "policy is managed by APF" |
| Gateway bindings | 4 RPCs (allowlist) |
APF's own declarations (dynamic): 10 RPCs, incl. post-commit audit |
| Agent gate and harness mounts | the interceptor's mount guard | APF's signed driver_config allowlist (below) |
charts/governance-policy stays the only place policy is written. A profile APF cannot express
is left out of the bundle, with a message when signing: today that is gemini, whose API key goes
in a query parameter; APF credentials have no field for the parameter name, and OpenShell rejects
the whole catalog without it. Sandboxes on apf cannot use such a provider. scripts/apf-bundle.py
translates policy.yaml into APF's SandboxPolicy and each profiles/<id>.yaml into an APF
Provider named after the profile id (the provider type users create).
The default is interceptor, so a clone of the pattern installs without APF's private images or
keys. Pick the engine when you install, without editing values-global.yaml:
./pattern.sh make install GOVERNANCE_ENGINE=apf # or interceptorGOVERNANCE_ENGINE becomes a Pattern extraParameter (global.governance.engine), which the
operator passes to every application and which wins over values-global.yaml. Pass it as a make
argument, as above: pattern.sh does not forward it from your shell. To switch back, run
make install again with GOVERNANCE_ENGINE=interceptor. Without GOVERNANCE_ENGINE,
values-global.yaml decides:
global:
governance:
engine: apf # or interceptorThe engine cannot come from values-secret.yaml: those values go to Vault, and Argo CD renders
the charts from Git and the Pattern's parameters, not from Vault.
It reaches two places:
charts/governance-interceptor(appgovernance-interceptor): withapfit stops rendering its own Deployment and Service and renders instead- ConfigMaps
governance-apf-bundle(the signed tarball) andgovernance-apf-trust(public key), - ExternalSecrets
ghcr-pull(image pull secret),governance-apf-signing(the seed) and, in the Argo CD namespace,governance-apf-chart-repo(lets Argo CD pull the private OCI chart), - Argo CD Application
governance-apf: the APF chart as releasegovernance-interceptor(fullnameOverridekeeps the Service name),openshift: true, pointed at the objects above, - the same NetworkPolicy, now selecting the APF pod.
- ConfigMaps
charts/openshell-saw, throughsaw-users(it passesglobal.governance): each gateway'sgateway.tomlusesbinding_policy = "dynamic"for APF. APF declares one binding per phase (three each forCreateSandboxandUpdateConfig, 14 in all), which OpenShell'sallowlistmode rejects ("declared multiple bindings"). Indynamicmode the gateway takes APF's own RPCs, phases and per-binding failure policy (fail-open for post-commit audit), limited to the RPCs OpenShell allows interceptors on. The endpoint andfail_closeddo not change; the interceptor engine keepsallowlistand its four bindings.
governance.bindings.apf in charts/openshell-saw/values.yaml can narrow APF's bindings (empty:
all of them), and governance.bindingPolicy sets the mode per engine.
- GHCR access. The APF images in
ghcr.io/mkhaas/apfare private for now, so you need read access to those packages and a GitHub token (classic) withread:packages. These packages will be published for general public access later; once they are, this step and the image-pull token are no longer needed. - Signing key.
make apf-keyswrites the seed to~/.apf-keys/apf.seed(mode 600, never in git) and the public key tocharts/governance-interceptor/files/apf/apf.pub. - Bundle.
make apf-bundlesignscharts/governance-policyintocharts/governance-interceptor/files/apf/bundle.tar.gz. Commit both files to the branch your pattern deploys from (your fork or deployment branch). - Secrets. Uncomment the
ghcrandapf-signingentries invalues-secret.yaml(~/.ghcr-tokenholds the token) and load them (./pattern.sh make load-secrets, or install). - Switch.
./pattern.sh make install GOVERNANCE_ENGINE=apf(or setglobal.governance.engine: apfand push).
Argo CD then replaces the interceptor with APF, and every SAW app (pattern and portal
workspaces) re-renders its gateway config. Running VMs do not pick it up on their own:
KubeVirt marks them RestartRequired and the gateway keeps its old bindings until the VM
reboots. Restart them once governance-interceptor is Healthy:
make saw-restart-pending # lists the VMs waiting for a restart
make saw-restart-pending CONFIRM=yes # restarts them; their sandboxes stop meanwhilemake saw-restart OPENSHELL_SAW_NAME=<user> restarts one VM. Until a VM restarts, its gateway
calls APF with the interceptor's four allowlist bindings, or, after switching back, the
interceptor with APF's dynamic bindings.
Switching an existing install: the governance-interceptor app prunes (values-prod.yaml), so it
drops its Deployment and Service when the engine changes and the governance-apf app takes over
the Service name. Argo CD waits for the ExternalSecrets before it creates governance-apf, so
load the ghcr and apf-signing entries first; if a sync gave up meanwhile, sync the app again
(oc -n vp-gitops patch application governance-interceptor --type merge -p '{"operation":{"sync":{}}}').
Switching back is the reverse, plus deleting the governance-apf Application, and again
make saw-restart-pending CONFIRM=yes: the restart is needed in both directions.
make apf-keys apf-bundle # your own key and bundle, not committed upstream
helm uninstall governance-interceptor -n openshell-agents # if the interceptor is installed
GHCR_USER=<github-user> GHCR_TOKEN=<token> make governance-apf
make saw-create OPENSHELL_SAW_NAME=alice GOVERNANCE_ENGINE=apf ...An existing quickstart SAW keeps its engine until you run make saw-create for it again with
the new GOVERNANCE_ENGINE and restart its VM (make saw-restart).
make governance-apf creates the two Secrets, installs charts/governance-interceptor as
release governance-apf-inputs (bundle, trust root, NetworkPolicy), and the APF chart as release
governance-interceptor with the same values the pattern's Application uses.
Edit charts/governance-policy, then make apf-bundle (it bumps policy_revision only when a
member changed) and commit the bundle with the change on your deployment branch. make apf-bundle-check verifies hashes,
the signature and that the bundle matches charts/governance-policy; with the pattern on apf
the test suite runs the same check, so a policy change without a re-signed bundle fails CI.
Only holders of the seed can change policy: that is the point of signing.
After make apf-keys --force (a new key), run make apf-bundle even when the policy did not
change: it notices that the bundle's signature no longer matches apf.pub and re-signs it with a
new revision.
APF refuses a bundle whose policy_revision is not above the last one it loaded, but it keeps
that counter in the pod (/var/lib/apf, an emptyDir in the APF chart, which has no setting to
persist it). Every new bundle rolls the pod, so a new pod would accept any validly signed
revision, including an older one. The check therefore also runs where the bundle comes from:
make apf-bundle-check and the test suite fail when the committed bundle's policy_revision is
not above every earlier commit of it, so reverting the bundle in git is caught before Argo CD
deploys it. To undo a policy change, revert charts/governance-policy and run make apf-bundle,
which signs the old policy with a revision above all of git history. A durable counter in the APF
pod needs an APF chart change (a state volume like audit.persistence).
APF reads the bundle at start: the chart restarts the APF pod when the ConfigMap changes
(its log says bundle verified and loaded … counter=<policy_revision>). Gateways pick up the
provider catalog when they restart.
APF puts the policy on a sandbox when it is created, and refuses changes afterwards. A sandbox
created before the new bundle keeps the old policy; recreate it to get the new one. Each sandbox
records which bundle it got in its apf.nvidia.com/* annotations (bundle-id,
document-digest, policy-version, and a provenance-token signed with your seed).
Example, verified live: network_policies in charts/governance-policy/policy.yaml let a
sandbox without providers reach a host. Before the change, git clone https://github.com/... in a
new sandbox was refused; after adding this, re-signing and pushing, a new sandbox cloned, while the
one created before still could not:
network_policies:
github_clone:
name: github-clone
endpoints:
- { host: github.com, port: 443 }
binaries:
- { path: /usr/bin/git }
- { path: /usr/lib/git-core/git-remote-http } # what git uses for HTTPS (git-remote-https links to it)binaries are the real executables: check them in the sandbox image with
readlink -f $(command -v git) and readlink -f $(git --exec-path)/git-remote-https.
This repository ships no key and no bundle: charts/governance-interceptor/files/apf/ is yours
to generate. A key in the shared chart would make every deployment trust bundles signed by one
person, and only that person could change policy. Each deployment makes its own key pair, keeps
the seed in Vault, and commits apf.pub and bundle.tar.gz to the branch its pattern deploys
from. With engine: apf and no bundle, the chart stops with a message pointing here.
The APF chart ships a demo key pair and uses it as the trust root when nothing else is given;
anyone with the chart can sign bundles against it. This setup never uses it: the trust root is
apf.pub from this repo and the seed comes from Vault. The key id stays apf-dev because the
APF chart mounts the files under that name.
scripts/apf-bundle.py signs exactly as apf-compile genfixture does (Ed25519 over the manifest
document, then a YamlSigilSignature.v1alpha1 document); it reproduces APF's demo bundle byte for
byte, so no private image or Docker is needed to sign.
Verified on OpenShift with OpenShell 0.1.2-rhaiv.0 and APF 0.1.0: the gateway starts with
APF's 14 bindings (gateway interceptors initialized bindings=14), provider creation is validated,
the SAW profiles are served (except gemini, below), new sandboxes carry APF's provenance, and a
re-signed bundle changes new sandboxes' policy. Confirm these on a new install:
- The gateway starts with APF's bindings (the VM's gateway log:
interceptors initialized); indynamicmode every post-commit binding must come withfail_openfrom APF's manifest. - OpenShell 0.1.x (the SAW's BOM since v0.1.2-rhaiv.0). The gateway negotiates the extension
protocol (
PeerMetadata, protocol 1.0) with every interceptor and stops at startup when an interceptor does not speak it ("did not provide protocol metadata"), so APF must be a build for OpenShell 0.1.x. That is whyapf.valuessets the0.1.0-driverconfig.3images: APF's 0.2.0 images were built against 0.0.x and a 0.1.2 gateway refuses them (seen live). 0.1.0 is APF's crate version, not an older release. - Profile annotations (NVIDIA/OpenShell#3929). 0.1.x hashes provider-profile annotations in map order, so a profile served with more than one annotation makes every sandbox with that provider fail ("Startup configuration did not stabilize"). The OpenShell interceptor build keeps one; check what APF puts on the profiles it serves.
openshell provider list-profilesin a SAW shows the SAW profiles (APF serves the catalog throughSnapshotProviderProfiles):openai,nvidia,braveandweb-search. APF's Provider schema accepts onlytype,endpoints,binariesandcredentials(name, envVars, required, style, header, source), so OpenShell'sinference_capable,category,discoveryandquery_paramare not in the bundle. OpenShell 0.1.x has no inference routing: the agent calls the provider's endpoint with a placeholder key, which needs the profile'sendpointsandbinaries(kept).- Expiring tokens. A credential's
sourcesays where APF gets it.host-envis a static value, so a token that expires (a Google access token, after about an hour) stops working. A profile credential with an OAuth2refreshtherefore becomessource: {kind: oauth2, grant: refresh-token, tokenUrl, refreshBeforeSeconds, parameters}, whose parameters are the refresh material the installer hands the gateway (client_id,client_secret,refresh_token). Profiles whose refresh is optional stayhost-env(OPTIONAL_REFRESHinscripts/apf-bundle.py): Slack, where a bot token (xoxb-) never expires. Check on a new install thatopenshell provider refresh status gmailshows a refreshed token after an hour. provider profile importof a profile that is in the bundle is allowed or refused cleanly (the installer treats a refusal as non-fatal).- The APF pod stays up with
interceptor.gatewayEndpointempty (SAW has a gateway per user VM, so APF has no single gateway to call back).
Every agent sandbox mounts a read-only gate volume at /sandbox/.saw/trusted and an append-only
audit volume at /sandbox/.saw/audit. An OpenClaw sandbox with a harness bundle
(harness-bundles.md) also mounts that volume read-only at /sandbox/harness.
The installer sends these exact combinations through CreateSandbox driver config. The gateway
sets allow_driver_config = true; APF admits only the installer's podman volume mounts from its
admin mTLS identity, using this signed SandboxPolicy block from scripts/apf-bundle.py:
driver_config:
deny_templates: true
allowed:
- require_role: openshell-admin
drivers: [podman]
max_mounts: 2
mounts:
- { type: volume, source_prefix: saw-gate-, target: /sandbox/.saw/trusted, read_only: true }
- { type: volume, source_prefix: saw-audit-, target: /sandbox/.saw/audit, read_only: false }
- require_role: openshell-admin
drivers: [podman]
max_mounts: 3
mounts:
- { type: volume, source_prefix: saw-harness-, target: /sandbox/harness, read_only: true }
- { type: volume, source_prefix: saw-gate-, target: /sandbox/.saw/trusted, read_only: true }
- { type: volume, source_prefix: saw-audit-, target: /sandbox/.saw/audit, read_only: false }APF denies a changed target, source prefix, read-only flag, mount count, driver, caller role, or
template. A bundle signed before this change denies the new agent mounts with
driver_config.denied; run make apf-bundle and push before applying this installer. There is no
APF signing key in this repository. Roles come from the gateway-authenticated identity.
interceptor.computeDriver: podman in apf.values tells APF which driver key to expect.
CreateSandboxTemplate is not interceptable in OpenShell 0.1.x, so APF covers templates at
CreateSandbox instead.
- Tier 2 (APF's egress content middleware on port 9099): needs TLS between the sandbox supervisor
and APF, and
network_middlewaresin the policy. - APF's Cedar rules, annotators and redaction effects: the bundle carries only the sandbox policy and provider profiles, which is what the interceptor enforced.
- Per-user or per-profile policy: APF injects one
defaultpolicy, like the interceptor. - A durable audit volume (
audit.persistencein the APF chart). - A durable anti-rollback counter in the APF pod: see Rollback.