Host policy and security
What a self-hosted Botyard runtime can and cannot do in your cluster: trust boundaries, the local host policy, sudo, the host-tool audit log and network controls.
This page is for the platform and security teams who run a Botyard bot's runtime (botyardd) in
their own Kubernetes cluster. It describes what Botyard can and cannot do on that compute, the
local policy you control, and what the audit log records. It also says where each control
stops. Read those parts before you enable anything.
For what runs where, read the self-hosted overview first.
Availability
Self-hosted runtimes are enabled per organization by Botyard. If your organization does not see the self-hosted option when it creates a bot, contact Botyard to request access. Kubernetes is the only supported platform today. Linux VMs and macOS are not yet available.
Trust boundaries
| Boundary | What holds | Where it stops |
|---|---|---|
| Outbound-only control plane | The runtime itself listens on no network port for Botyard: it opens its own HTTPS/WSS connection to Botyard, and Botyard sends work over that connection. The chart creates no Service and no Ingress. | Botyard still sends work over the connection the runtime opened. Anything the bot is assigned and your local policy allows runs when Botyard asks for it. This covers the runtime's own transport only. Commands it starts through exec, and services it supervises, can open listeners and connect on other ports. Having no Service or Ingress does not stop direct connections to the Pod IP. Use the chart's NetworkPolicy (off by default) or your own ingress controls. See Network. |
| No model-provider credentials on your compute | Model calls are brokered. The runtime sends only a request body over its Botyard connection, and Botyard calls the model provider with its own credentials. | This covers Botyard's provider credentials. Credentials you or the bot place in the workspace, or inject through secret leases, are on your compute. |
| Secrets through short-lived leases | The runtime holds references, never secret values. When a command needs a secret, Botyard resolves the lease server-side and returns the value once, for one process. The runtime keeps it in memory only and never logs it. | After it is injected, the value is in that process's environment. Other processes running as the same user can generally read it, including a command started through exec. |
| Enrollment token kept out of the runtime | The single-use enrollment token is read from a Kubernetes Secret. The container's entrypoint removes it from the environment before it starts the runtime, so neither the runtime nor any command it starts holds it. On every restart after the first, the token is not used. | The token stays in the Secret, and a shell you open with kubectl exec gets it from the pod spec. After the first successful start, delete it from the Secret if you want it gone. |
| Device key on your volume | The key that identifies the runtime to Botyard is created on the persistent volume, in a directory the runtime owns (0700). The volume is the device. | The key is a file on disk (the file custody tier). Anyone who can read the volume can impersonate the device, and so can exec in the pod (see What root does not bound). KMS and TPM custody are not yet supported. |
| Locked-down container | Runs as UID/GID 10001, never as root. Meets Pod Security Admission restricted: read-only root filesystem, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp. No service-account token is mounted. The image has no sudo, and setuid/setgid bits are stripped from every binary. | This is container isolation inside your cluster. It does not limit what the runtime's own user can do inside its own pod. |
The local host policy
The local host policy is the operator's ceiling on what the runtime may do on your compute. It
is a JSON document in the BOTYARD_HOST_POLICY environment variable. Set it in one of two ways:
hostPolicyin the Helm chart's values. The chart renders it into a ConfigMap and adds a checksum annotation to the pod, so changing it rolls the pod.hostPolicyConfigMap.name(and.key, defaultpolicy.json), pointing at a ConfigMap you manage. The chart cannot see its contents, so changing it does not roll the pod. Restart the pod yourself.
The runtime reads the policy once, at startup. A change takes effect only after a restart. If the value is not valid JSON, is larger than 64 KiB, or contains an unknown field, the runtime refuses to start. It never falls back to a more permissive default.
hostPolicy:
enabled: [read, write, patch]
root: /var/lib/botyard/workspaces| Field | Default | Meaning |
|---|---|---|
enabled | [] | Operations the runtime may perform. Empty means none, not all. Values: read, write, patch, exec, process_status, process_cancel, sudo_exec, service_register, service_start, service_stop, service_restart, service_status, service_list, service_logs, service_unregister. |
root | none | Absolute path of the file-tool root. Required for every operation, including exec. Without it nothing is allowed. |
service_owner | disabled | native lets the runtime run supervised services. The service_* operations are refused unless this is native. |
allow_sudo | false | Operator opt-in for sudo_exec. See sudo. |
sudo_executables | [] | Absolute executables sudo_exec may run. Empty means unrestricted, not none. |
child_oom_score_adj | 500 | OOM-killer preference for commands the runtime starts (1–1000), so a runaway command is killed before the runtime is. |
browser | none | The runtime-owned browser. Requires service_owner: native. |
Deny by default
With no policy, or with enabled empty, the runtime performs no host operation. That is the
chart's default (hostPolicy: {}).
What the policy guarantees is that the runtime performs nothing. It does not decide which tools the model is offered. That depends on what the bot is assigned in Botyard, and Botyard does not use your local policy to filter the assignment. So under an empty policy:
- a host tool the bot is not assigned is not offered. The bot may answer that the tool (for
example
process_start) isn't available; - a host tool the bot is assigned can still be offered. If the bot calls it, the call reaches the
runtime, which refuses it and writes an audit line with
decisiondenyandreasonlocal_policy. For a well-formed request, that line includes the same metadata as for an allowed call:exeandargcfor a command, orpathfor a file tool.
Either way, no command runs and no file is read or written. To let the bot use host tools, set
hostPolicy.enabled and hostPolicy.root
(Host policy values).
A few other operations are gated by enabled members, so they are refused unless the member is
listed:
| Operation | Allowed only when the policy allows |
|---|---|
| Brokered Git, loopback readiness probe, process watches | exec |
| Viewing an image from the workspace, uploading a workspace file to Botyard | read |
| Downloading a file into the workspace | write |
| The browser | the browser block |
The one exception is the runtime's in-memory busy markers, which Botyard uses to tell whether the runtime is busy. They touch no file, process or network resource, so they are served under any policy.
Effective policy: assigned in Botyard ∩ allowed locally
A host tool runs only when both of these are true:
- Botyard asks for it. The tool must be assigned to the bot in Botyard. For self-hosted bots, Botyard never fills in a default policy: it asks only for what the bot's configuration says.
- Your local policy allows it. The runtime checks the local policy on every call, whatever Botyard asked for.
Botyard cannot change your local policy. Nothing it sends over the connection modifies it. The only place to change it is your cluster.
What root bounds
root bounds the file tools (read, write, patch). File paths are resolved relative to
the root and opened one component at a time, without following links. Symlinks are refused, even
ones that point inside the root. Only regular files are read or written.
The chart also refuses a root that is, contains or sits inside the runtime's state directory
(/var/lib/botyard/state, which holds the device key). The root must be on the volume
(/var/lib/botyard/...) or under /tmp. The chart creates it with an unprivileged init
container. With hostPolicyConfigMap, the chart cannot see the root, so it must already exist,
and the chart cannot check where it is.
What root does not bound
root does not confine exec. A command started with exec runs as the runtime's own user
(10001) with that user's full permissions in the pod. It can:
- read the state directory, including the device key;
- generally read the environment of other processes running as the same user, including injected secrets;
- reach anything the pod's network egress allows.
The host policy is not an OS sandbox. Enable exec only for bots you would trust with the
device's identity. To limit what exec can reach on the network, use the
NetworkPolicy or your own egress controls.
sudo
allow_sudo is an operator opt-in. The runtime reads it only from BOTYARD_HOST_POLICY, so
Botyard cannot turn it on for you, and Botyard never applies a default policy to a self-hosted bot.
For sudo_exec to run a command, all of these must hold:
sudo_execis assigned to the bot in Botyard;sudo_execis inenabled,allow_sudoistrue, androotis set;- the executable is in
sudo_executables, orsudo_executablesis empty, which means unrestricted; - the OS
sudopolicy allows it. The runtime runs/usr/bin/sudo -n -- <command>.
sudo_exec is served only to Botyard. A harness on the same machine cannot call it through the
local relay.
sudo does not work on the Helm chart
The self-hosted image ships no sudo binary and no setuid binaries, and the pod runs with
privilege escalation disabled. Setting allow_sudo: true in the chart therefore cannot elevate
anything: sudo_exec calls fail. Leave allow_sudo off. The Local host policy panel still
lists sudo_exec as allowed if you configure it, because the panel shows your policy, not what
the image can do.
Seeing the policy in Botyard
For a self-hosted bot, User menu › Settings › Bots › ‹your bot› › Device has a Local host policy card, marked Reported by the runtime. It shows Allowed operations, File root, Sudo, Services and Browser.
- Allowed operations lists what the runtime would actually admit, not everything you wrote:
sudo_execappears only withallow_sudo, service operations only withservice_owner: native, and nothing appears without aroot. - The runtime sends the report each time it connects. It is read-only and Botyard cannot edit it.
- It is self-reported by the runtime. Botyard does not verify it and does not use it to decide anything: the runtime enforces its own policy on every call.
- If the report had to be shortened to fit its size limits, the card says so. In that case the file root and the sudo list may be incomplete.
Host-tool audit log
The runtime writes one JSON line to stdout for every host-tool call it handles, whether the call ran or was refused. Its own diagnostic logs go to stderr. The audit log is produced on your compute and is not sent to Botyard. Botyard keeps its own audit of the calls it makes. This log exists so you do not have to rely on Botyard's.
Lines appear only for calls that reach the runtime. With the chart's default empty policy every command or file call that reaches it is refused, so any such line is a refusal. Whether you see any at all depends on what the bot is assigned. See Deny by default.
BOTYARD_HOST_TOOL_AUDIT controls it:
- unset: on for a self-hosted runtime;
onoroff(case-insensitive) to choose explicitly. Set it through the chart'sextraEnv;- any other value: the runtime refuses to start.
Line format
Every audit line starts with {"botyard_audit":"host_tool":
{"botyard_audit":"host_tool","ts_ms":1793738763311,"bot_id":"…","turn_id":"…","source":"control_plane","tool":"process_start","exe":"git","argc":2,"decision":"allow","is_error":false,"exit_code":0,"duration_ms":412}Fields appear in this order. Fields marked optional are left out when they have no value.
They are never null.
| Field | Meaning |
|---|---|
botyard_audit | Always host_tool. |
ts_ms | Unix time in milliseconds. |
bot_id | Optional. The enrolled bot's ID. |
turn_id | Optional. The Botyard turn the call belongs to, when Botyard attributed it to one. Never present on local_relay calls. It is a label only and never authorizes anything. |
source | control_plane (Botyard named the tool) or local_relay (a harness on this machine named it). |
tool | The tool name, for example file_read, process_start or sudo_exec. |
exe | Optional. For process_start and sudo_exec: the basename of the executable only. |
argc | Optional. For process_start and sudo_exec: how many arguments were passed. Never the arguments themselves. |
path | Optional. For file_read, file_write and file_patch: the root-relative path, cut at 512 characters. Never the content. |
decision | allow (it ran, though it may still have failed) or deny (refused before running). |
reason | Optional, on deny. local_policy (your policy does not allow it), unknown_tool, or not_on_this_seam (for example sudo_exec from the local relay). |
is_error | Optional, on allow. Whether the tool reported failure. |
exit_code | Optional, on allow. The process exit code, when the result carries one. |
duration_ms | How long the runtime spent on the call. |
exe, argc and path are filled in only when the request was valid for that tool. Any other
tool, an unknown name or a malformed request gets none of them.
Never in the log: command arguments, file contents, tool output, or secret values.
What the audit log does not cover
The log records calls to host tools. It does not record what a command does after it starts: child processes, files it touches, or network connections it opens. To audit those, use your own runtime security tooling.
Delivery and overflow
Writing an audit line never delays a tool call:
-
Lines go into a bounded in-memory queue of 1,024 lines, written by a dedicated thread.
-
If the queue is full, the line is dropped and counted. Before the next line it writes, the writer emits a marker with the count, so a gap is always visible:
{"botyard_audit":"host_tool_dropped","ts_ms":1793738763999,"dropped":17} -
If writing to stdout fails, a warning goes to stderr and that line is lost. The runtime keeps serving.
-
At shutdown the runtime waits up to two seconds to flush. Lines still queued after that are lost.
The audit log is therefore best effort, not a guaranteed record. Alert on host_tool_dropped.
Shipping to a SIEM
The runtime does not ship the log anywhere itself. Your cluster's log pipeline collects the
container's stdout like any other container log (for example Fluent Bit, Vector, or the
CloudWatch agent on EKS). Route lines whose message starts with {"botyard_audit": to your audit
sink. That prefix matches both host_tool and host_tool_dropped lines. Treat everything else
as diagnostic logging.
To check the lines by hand:
kubectl -n <namespace> logs <release>-botyard-runtime-selfhosted-0 | grep '^{"botyard_audit":'Supply chain
Releases of the image (ghcr.io/botyard-ai/runtime-selfhosted) and the Helm chart
(ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted) are signed keyless with cosign:
- The signing identity is the GitHub Actions workflow
release-runtime-selfhosted.yml, running on av*release tag. - Signatures are recorded in the public Rekor transparency log.
- Botyard holds no long-lived signing key.
- The image carries an SPDX SBOM attestation.
Verify the signature and SBOM before you install, and pin the image by digest. The verification commands are in Verify what you pull.
Network
For its own control-plane traffic, the runtime makes only outbound connections, over TCP 443 (HTTPS and WSS), and needs DNS. It connects to:
- the Botyard API,
https://api.botyard.io; - the runtime gateway. Botyard names its origin on each connection; today it is
wss://runtime.botyard.io; - object storage, when a workspace file is uploaded to or downloaded from Botyard. The runtime uses a presigned URL that Botyard supplies for that one transfer, so the host is whatever Botyard's storage uses.
Botyard does not yet publish a complete, stable hostname list. If you allowlist by hostname,
expect it to need updating. The runtime also works through an HTTP CONNECT proxy (proxy.url or
proxy.existingSecret).
The chart can create an egress-only NetworkPolicy. It is off by default:
networkPolicy:
enabled: true
egressCIDRs: ["10.20.0.15/32"] # e.g. your proxy; empty = any destination
extraEgressPorts:
- port: 3128
protocol: TCPWhen enabled, the policy:
- denies all ingress;
- allows DNS (UDP and TCP 53) to any destination;
- allows TCP 443, plus any
extraEgressPorts, to any destination. If you setegressCIDRs, only to those CIDRs.
Keep in mind:
- The TCP 443 list above is the runtime's own traffic. Commands started through
exec, and services the runtime supervises, are ordinary processes in the pod. They can open listening sockets and connect on any port your network allows. With the NetworkPolicy off (the default), the chart does not restrict ingress to the Pod IP. The absence of a Service or Ingress only means nothing advertises the pod. To block inbound connections, enable this policy or apply your own NetworkPolicy or firewall controls. - The policy is per pod. It applies to everything in the pod, including commands started through
exec. WithegressCIDRsempty, those commands can reach any host on port 443. - It filters by IP address, not hostname. To allow only Botyard's hostnames, send traffic through
your proxy and allowlist the hostnames there, then set
egressCIDRsto the proxy's address. - A NetworkPolicy only takes effect if your cluster's network plugin enforces NetworkPolicy. On EKS with the Amazon VPC CNI, network-policy support must be turned on.
Install on Kubernetes
Install a self-hosted Botyard bot on Kubernetes: verify the signed image and chart, keep the enrollment token in a Secret, run helm install, check it is online.
Operations and troubleshooting
Run a self-hosted Botyard runtime day to day: status, logs and audit lines, upgrades, restarts and storage, offline behaviour, and start-up refusals.