Configuration reference¶
Executor backends, sandbox settings, transport security and hardening keys for
.cloop/config.yaml.
This page is the key-by-key reference. For the concepts, see how executors work, what each boundary authenticates with and granting credentials.
Per-user state: CLOOP_HOME¶
Most configuration is per project and lives in .cloop/config.yaml in the
working tree. A few things are per user and live outside it: the multi-project
registry the dashboard lists, the global budget and cost ledger, named profiles,
and cached provider credentials:
| Path | Contents | Relocated by CLOOP_HOME |
|---|---|---|
~/.cloop/projects.json |
The multi-project registry: which projects the dashboard shows | yes |
~/.cloop/profiles.yaml |
Named provider profiles | no |
~/.cloop/agent.json |
Remote executor agent credential and pinned certificate | no |
~/.cloop/plugins/ |
User-level plugins | no |
~/.config/cloop/budget.yaml |
Global spend caps | no |
~/.config/cloop/costs.jsonl |
Global cost ledger | no |
~/.config/cloop/workspaces.json |
Registered workspaces | no |
CLOOP_HOME replaces ~/.cloop. Like CARGO_HOME, it names the directory
itself, not a home directory that contains it. A relative value is resolved
against the process's working directory, and an empty or whitespace-only value
counts as unset.
It currently moves only the registry, as the right-hand column shows. Profiles,
plugins and the agent credential still resolve from $HOME, so setting
CLOOP_HOME alone doesn't give a second hub its own copy of them. To move
everything, set HOME. You can then also set CLOOP_HOME to put the registry
somewhere else again.
There are two reasons to set it:
- Running more than one hub under one Unix account. Without it the hubs share a registry, so projects registered in one appear in the other. Members of one hub cluster need that sharing, so don't give them different values.
- Testing. The registry is process-wide state at a fixed path outside the
working tree, so a test can leave entries in the real registry of the machine
it runs on. One dashboard test added an entry to a developer's
projects.jsonon every run until project index 99 pointed at a deleted directory, which broke three unrelated tests on that machine only (CI always starts with an empty$HOME). cloop's Go tests isolate themselves withinternal/hometest.IsolateinTestMain, which redirects$HOMEand clearsCLOOP_HOME.tests/hermeticfails the build if a package that reads$HOMEand has tests doesn't do this.
Background work left by an agent¶
An agent can start a long job (nohup python train.py &, a background build, a
test run it never waits for), exit before the job finishes, and print
TASK_DONE. The transcript shows what the agent started but not whether it
finished, so without a check cloop would mark the task complete and run the next
one against output that is still being written. In the case that led to this
check, three tasks in a row worked on a model that the task before them was
still training.
cloop detects this. The claude CLI (and the copilot CLI, for the
copilot provider) runs in its
own process group, so cloop can still find anything it started after it exits.
When the harness returns, cloop scans that group:
- Nothing left running: the common case. It costs one procfs scan, and the task proceeds normally.
- Something exits within the grace window: ordinary teardown, ignored.
- Real background work: cloop waits until it finishes, then accepts the task. The UI and the event log show the wait while it happens.
- Work outlives the wait: the task is not accepted as complete, whatever its output says. It is marked failed with a diagnosis naming the surviving processes, dependent tasks stay blocked, and the leftover processes are terminated so a retry can't collide with them.
Configure it under the provider:
claudecode:
background:
disabled: false # true restores the old trust-the-transcript behaviour
grace_seconds: 2 # teardown window before survivors count as real work
wait_minutes: 30 # how long to wait; negative reports without waiting
keep_orphans: false # true leaves survivors running (task still fails)
copilot.background takes the same four keys for the copilot provider.
grace_seconds is capped at 120 and wait_minutes at 1440. A larger value is
clamped, with a warning.
Raise wait_minutes if your tasks are expected to start long jobs. Waiting too
long costs a slow task; waiting too briefly costs a retry of a task that would
have succeeded. Set keep_orphans: true only if something outside cloop cleans
up those processes.
Limit: a child that calls setsid() leaves the process group and is not
detected. This is intended: calling setsid is how a process says it means to
outlive its parent, and this check is for work that was meant to be waited for.
Requirement-gated completion (requirements)¶
A project can require its tasks to meet their requirements. With the gate on, a
task the agent reports as done is accepted only once its linked requirements
pass their tests on the project's executor. In linked+must mode, every
approved must requirement that has tests has to pass too. The setting is stored
with the project, and config.yaml can declare it:
requirements:
gate: linked # off | linked | linked+must
assess: false # ask the model about linked requirements without tests
| Key | Default | What it does |
|---|---|---|
requirements.gate |
not declared | cloop run writes it into the project before it starts. If it isn't declared, the project keeps whatever the dashboard's Requirements badge or cloop requirements gate set last. A mode this build doesn't know declares nothing (it is not treated as off), and every load warns about it. cloop config set and cloop config validate refuse it. |
requirements.assess |
not declared | With the gate on, asks the model about linked requirements that have no tests. One it finds unmet blocks the task. Without it, such requirements are reported as unverified and never block. |
See Requirement-gated completion for what a check does, how a task is sent back, and how a regression is reported.
Following new builds¶
A deploy replaces the cloop binary but not the runs started from it, and a run in auto-evolve doesn't end on its own, so it would keep running its original build indefinitely. Two settings let a run on this host switch to a newly deployed build at a task boundary, keeping its process, its pid and the hub's live log. The runbook's Long-lived runs after an upgrade describes what happens and what each refusal means.
| Setting | Where | Default | What it does |
|---|---|---|---|
| Follow New Builds | Per project: the Overview's Active Options badge, cloop run --follow-builds (=false to turn off), POST /api/options/toggle {"flag":"follow_builds"}. Stored in the project's state.db (metadata key follow_builds), not in config.yaml |
off | At every task boundary the run checks the binary at the path it was started from. It switches to that binary once it is a different file that has been in place for three minutes (a deploy that fails its health check is rolled back well within that), answers version --json with a higher sequence and a schema at least the database's, and accepts the run's command line. A file it refused isn't checked again until it changes. A failed probe is tried three times. |
| Adopt at next task boundary | One-shot, per run: the Overview button, or POST /api/run/adopt-build (run.start) |
— | The same check and switch, once, for the current run, without the three-minute wait but never to a build newer than the hub that asked. The request names that run's process and is cleared once the run has acted on it or refused it. |
Both apply only to host-process runs (the local executor) on Linux. A run in a container or on a remote device uses the binary its image or agent provides, and changes when they do: rebuild the image, or upgrade the device from the Executors panel. For those runs the dashboard says so instead of offering the button.
What the run records about itself, and where:
- The run-owner record (metadata key
run_owner): pid, start time and boot id, host, the path it was started from, executor kind, run id, and its build (version,commit,sequence,schema), plusreexecsand thepreviousbuild once it has switched. Only the run writes it. The dashboard (run_buildin/api/state),cloop statusandcloop hub doctor(runs.build_lag) read it. - A pending request (metadata key
run_adopt_request), written only by the hub. CLOOP_RUN_HANDOFFnames.cloop/run-handoff-<pid>.json. A run sets it in its own environment as it re-executes itself, and the new image reads the file, removes it and unsets the variable. Never set it yourself: a process started with it skips everything a fresh run does at startup.
A candidate build must report schema in cloop version --json. Older builds
don't, and are refused.
Execution Backends (Executors)¶
An executor decides where a cloop workload runs. By default it is a child process on the same host as cloop, with the same user, filesystem and network. That is fine on a laptop, but not for a hosted deployment where the agent runs model-written code on someone else's behalf.
Each project is bound to an executor. If that executor isn't available, the project's runs fail. They never fall back to running on the host, so an isolation boundary you chose isn't lost because a backend was unreachable.
| Kind | Isolation | Where it runs | Notes |
|---|---|---|---|
localprocess |
none | this host | Default. No configuration, no sandbox. |
container |
container, or vm |
this host | Docker/Podman sandbox, enabled in the config. vm with a Kata oci_runtime. |
remote |
remote | an enrolled device | An edge machine that connects out to the control plane. |
kubernetes |
remote | an ephemeral Pod | One Pod per workload. Also VM-backed with a Kata runtime_class. |
cloop executor list # what is registered, and what it isolates
cloop executor ls # fleet health: state, in-flight work, last seen
cloop executor test <id> # preflight + run `cloop version` inside it
cloop executor reap <id> # remove containers/Pods left by a killed control plane
cloop executor cordon <id> # stop new placement; in-flight work continues; a device's leases come back
cloop executor uncordon <id> # back in rotation, at the health its probes justify
cloop executor drain <id> # as cordon, then wait for in-flight to reach zero
cloop executor enroll # mint a token for a remote device
cloop executor agent --server … # run *on* the device
cloop executor revoke <id> # kill a leaked token or a decommissioned device; drops it at once
The Executors panel¶
The web dashboard has a global Executors tab with one card per backend: a live status dot, a kind badge (host / container / remote), capability chips, current load, and enroll and revoke actions. Status changes arrive over the same WebSocket as the rest of the dashboard, without polling.
You choose a project's executor on the Executor card of its Overview page, next to the provider card. A change takes effect on the next run. Work already in progress stays on the executor that started it.
The tab's banner always shows the effective host-execution mode, so you can see whether this server may still run a harness on its own host without reading config files.
Container sandbox¶
Enable it in .cloop/config.yaml:
executors:
container:
enabled: true
runtime: podman # or docker; empty auto-detects (podman preferred)
oci_runtime: "" # low-level runtime the CLI delegates to; empty = runc/crun
image: ghcr.io/blechschmidt/cloop-harness:latest
cpus: 2 # core allowance per workload
memory: 2g # 512m / 2g / 1024k; a bare integer means MB
pids_limit: 1024 # process cap; -1 disables
network: none # none (default) | bridge | <named network>
extra_args: [] # additional runtime flags; each must start with "-"
selinux_label: "" # "z" or "Z" — required when SELinux is enforcing
orphan_grace_period_seconds: 600 # 0 means 600; see below
labels: # what a grant to label:k=v is matched against
region: eu
Or use cloop config set executors.container.<key> <value>. The setter accepts
enabled, id, runtime, oci_runtime, image, cpus, memory,
pids_limit, network, allow_hosts, extra_args and selinux_label.
orphan_grace_period_seconds, allow_root_user and the whole egress_filter
subtree have no setter key, so you edit them in the file.
labels are the executor's labels, which a grant to a label
selector is matched against.
executors.kubernetes.labels does the same for the Kubernetes executor. Like
the rest of the section, they are read from the configuration the hub starts
with. After a restart that changes them, the leases the hub adopts are checked
against the new labels, and credentials the new labels no longer match are
given back. A key containing a space, , or =, or a value containing ,, is
refused by cloop config validate and dropped with a warning on load, because
no selector could name it.
orphan_grace_period_seconds is how old a running container must be before the
startup sweep and cloop executor reap will kill it. Exited containers are
removed immediately, whatever this says.
The grace period is what makes it safe to remove running orphans. A container
listed a moment ago may belong to a control plane that is starting right now,
or to this one in the instant between run -d returning and the handle being
recorded, and neither is an orphan. A container that has run untracked for
longer than the grace period has no owner. Zero means the 600-second default,
so an unset field never means "remove on sight". The accepted range is
0–604800 (7 days). A value outside it is reported and reset to the default, not
clamped. executors.kubernetes.orphan_grace_period_seconds is the same key,
with the same default and meaning, for Pods.
Ten minutes is far longer than the millisecond race it guards against, which errs on the safe side: removing an orphan late costs some CPU, but removing one early kills someone's run. Lower it only on a host where nothing else shares the container runtime. See orphan grace periods for the other conditions before a running container is touched, and after a control-plane restart for the log line that reports one.
Every container is started with:
- only the project directory bind-mounted, at the fixed path
/workspace. Nothing else on the host is visible. - a non-root UID, taken from the owner of the project directory, so files
the workload creates have the right owner on the host and an escape lands on
an unprivileged user. Under rootless podman,
--userns=keep-iddoes the same. --network=noneby default. Egress is enabled per executor.- all Linux capabilities dropped and
no-new-privilegesset, so a setuid binary in the image can't change the UID. --cpus/--memory/--pids-limit, with swap set to the memory limit so swapping can't get around it.- a predictable name,
cloop-<project>-<runID>, so orphans can be found and removed.
Secrets (provider API keys, brokered credentials) are passed as environment
variables at start, using the bare --env NAME form. The runtime reads each
value from its own environment, so no secret appears in the host's process
table. Nothing is baked into an image or written to the mounted working
directory.
Unlike a host process, a container workload doesn't inherit the control plane's environment, which would hand the sandbox every credential the server holds. Variables are passed explicitly or not at all.
What the container sandbox does not do¶
- It doesn't filter egress unless you configure it to. Any
networkother thannonegives unrestricted outbound access unless the named network has its own policy, andcloop executor testwarns about this. There are two supported ways to give a sandbox limited network access: keepnetwork: noneand grant egress through the broker (Scoped network egress), or turn onegress_filter, which enforces the same allowlist at the IP layer. You can use both, and the filter is what stops a workload that ignores the broker. - It doesn't pull images. Runs use
--pull=never, so a missing image fails at once with an error that says what to do, instead of a click in the UI hanging for minutes. Preflight tells you what to pull. - It doesn't set disk quotas. Writable-layer quotas work on only a few
storage-driver configurations, so a
DiskMBlimit (resources.disk, or the operator's disk ceiling) is enforced by measurement instead. The image's root filesystem is read-only and/tmpis a capped tmpfs, so the work lands in the mounted workspace. The driver measures that tree before the run starts and at intervals while it runs, and stops the workload once it is over the limit. A burst of writes can overshoot the limit until the next measurement.
extra_args is validated. Flags that would undo the sandbox (--privileged,
--cap-add, --volume, --network, --user, --entrypoint, --env, …) are
rejected, and every entry must start with -, so a bare value can't be taken as
the image reference. Prefer --flag=value: only the part before the = is
checked against the denylist.
IP-layer egress filtering¶
Scoped network egress, below, is an HTTP proxy. It
enforces hosts, methods and quotas, but it only sees traffic the workload
chooses to send through it. A harness that opens a raw socket, runs
curl --noproxy '*', or uses a protocol other than HTTP bypasses it.
egress_filter covers that case: a packet filter compiled from the same
allowlist and enforced by the kernel, whether or not the workload cooperates.
The recommended configuration is two lines:
internal: true puts sandboxes on a container network with no route off the
host. Nothing on it can reach the Internet, so with the egress broker on the
host, the broker is the only way out, and its host allowlist is actually
enforced. This needs no nft, no CAP_NET_ADMIN and no other host privileges.
The rest of the section describes direct egress, for destinations a sandbox
must connect to itself, such as a Kubernetes API server or an internal registry.
Direct egress installs an nftables ruleset on the sandbox bridge from the host
side, so it needs nft(8) on the host and CAP_NET_ADMIN in the control plane.
executors:
container:
network: bridge
egress_filter:
enabled: false # default; see "off by default", below
internal: false # route sandboxes through the broker only
allow_cidrs: [] # ranges a sandbox may dial directly
allow_ports: [] # required whenever a destination is allowed
allow_public_internet: false # every address outside the block set
resolvers: [] # DNS servers it may query directly
broker: "" # egress proxy endpoint, address:port literal
host_patterns: [] # the L7 allowlist this filter cannot enforce
drop_denied: false # drop denied packets instead of refusing them
| Key | Meaning |
|---|---|
enabled |
Turns the filter on. When it is off, the configured network is used as it is and the other keys have no effect |
internal |
Sandboxes join a network with no route off the host. Needs no privileges |
allow_cidrs |
Address ranges a sandbox may connect to directly (10.8.0.0/24). The only setting that opens private address space, and it opens only what it names |
allow_ports |
Destination ports that limit every allow rule above. Required whenever allow_cidrs or allow_public_internet is set |
allow_public_internet |
Every address outside the blocked ranges, on allow_ports. This is what a hostname allowlist turns into at layer 3 |
resolvers |
DNS servers the sandbox may query directly, as address literals (10.7.0.10, or with a port). Opened on both UDP and TCP |
broker |
The egress proxy endpoint, as an address:port literal. Only needed with direct egress; on an internal network the broker shares the bridge |
host_patterns |
Records the L7 allowlist this filter can't enforce, so the rendered ruleset and the preflight report can show what was widened |
drop_denied |
Drops a denied packet without a reply instead of refusing it. When off, a blocked connection gets a TCP reset or an ICMP "administratively prohibited", so the workload fails at once instead of after its own timeout, and the refusal is logged and shown on the run (diagnosing a blocked connection) |
Off by default. Turning a firewall on during an upgrade could break an existing deployment in a way that looks like a network outage, so you turn it on yourself, once, in the file. Until then, preflight reports a warning, because saying nothing would suggest that egress is restricted.
The section is validated against four rules. Each one refuses the configuration instead of guessing:
allow_cidrsopens only what it names. Listing10.8.0.0/24doesn't lift the block on the rest of10.0.0.0/8. A/0is refused, because it would remove the blocked ranges altogether, and so is any prefix that covers a cloud metadata service without naming its address exactly. The services are listed in one table (pkg/cloudmeta):169.254.169.254, AWS's IPv6fd00:ec2::254insidefc00::/7, Alibaba Cloud's100.100.100.200inside100.64.0.0/10, and the others each cloud publishes.cloop uirefuses to start while anallow_cidrs/cidrsentry contains one without naming it. The error names the service and the alternatives: add its/32to the allowlist, or allow a range that leaves it out.cloop hub doctorfails the same check (firewall.metadata). A filter compiled from a stored rule keeps the service closed in any case, because it is dropped before the allow rule.- Destinations without ports are an error. An allow rule with no port restriction is a hole, and the compiler won't guess ports such as 80 and 443 for you.
brokerandresolverstake address literals, never names. A packet filter matches addresses, so a name would be resolved once when the configuration is loaded and then fixed without notice. That is the DNS rebinding risk the broker's own resolve-once rule prevents.enabled: truewithnetwork: noneis refused, because a workload with no network interface has nothing to filter. The error says which of the two to change.
host_patterns can't be enforced, and the output says so. *.github.com is
a name, and names don't exist at layer 3. Direct egress compiled from a host
allowlist is "every public address on allow_ports"; nothing narrower is
possible. Listing the patterns here doesn't enforce them. It adds a warning to
the compiled policy that names them and what they became, which appears in the
nft ruleset as a comment and in cloop executor test as an egress-scope
finding. If you rely on the host allowlist, use internal: true.
On Kubernetes: a NetworkPolicy per Pod¶
executors.kubernetes.egress_filter compiles the same allowlist into a
NetworkPolicy that is created with each Pod and selects only that Pod. The
keys differ slightly from the container section. There is no internal and no
broker, because a Pod's network belongs to the cluster, and there is one new
key:
executors:
kubernetes:
enabled: true
egress_filter:
enabled: false # default
cidrs: [] # destination ranges (note: not allow_cidrs)
ports: [] # required whenever a destination is allowed
allow_public_internet: false
resolvers: [] # only when not using cluster DNS
allow_cluster_dns: true # UDP and TCP 53 to kube-system; unset means true
allow_cluster_dns is on unless you set it to false, and should stay on. A
default-deny egress policy without it breaks name resolution, which looks like
a broken network instead of blocked DNS. It is written as a namespaceSelector
on kube-system instead of an address, because cluster DNS runs on a Service
ClusterIP in private address space, and CNIs differ in which side of the policy
they apply DNAT on.
The Kubernetes filter is off by default for the same reason as the container
filter, and also because an empty allowlist denies everything, including
wherever your harness clones from. When you turn it on, also set
cidrs/ports or allow_public_internet.
The filter needs two things from the cluster that cloop can't provide:
- RBAC. The executor's identity needs
networkpolicies: [create delete list]in the workload namespace. Preflight lists NetworkPolicies to confirm the leased identity has these permissions. A403is afail, because everyStartwould then refuse to run instead of starting a Pod with unfiltered egress. The Helm chart'sexecutor.kubernetes.rbac.creategrants them. - A CNI that implements NetworkPolicy. flannel doesn't; Calico, Cilium,
Antrea and most managed CNIs do. The API server stores the object either way,
so from the hub's side a cluster with the wrong CNI looks the same as a
working one.
cloop executor test kubernetesalways shows this as anegress-enforcementwarning.
A malformed egress_filter disables the Kubernetes executor, with a message
naming what wouldn't compile. It is never reset to a default. The container
section is validated the same way, through its driver options, so a bad value
there stops the executor from being built. In the chart, the same settings are
executor.kubernetes.egressFilter.{enabled,cidrs,ports,allowPublicInternet,resolvers,allowClusterDNS}.
You can preview any of this before writing it, without changing the host:
cloop egress firewall --cidrs 10.8.0.0/24 --ports 6443 --format rules
cloop egress firewall --internet --ports 443 --check 169.254.169.254:80
See cloop egress firewall for the
command, and the executor architecture
for how the two mechanisms are chosen and why the ruleset is installed from the
host side.
On Kubernetes: the git proxy's CA, for its URL only¶
Git in a Pod verifies the certificate of the
git interception proxy, and a proxy served under an
in-cluster Service name has a certificate that no public CA issued. On a device
the operator handles this in the image or with GIT_SSL_CAINFO. A Pod's spec is
written by the driver, so the driver handles it:
executors:
kubernetes:
git_ca_bundle:
config_map: cloop-hub-monitor-ca # in the workload namespace
key: ca.crt # the default
urls: ["https://cloop-hub.cloop.svc:8443"] # executors.git_proxy.advertise_url
Every Pod mounts the entry read-only at /etc/cloop/git-ca/ca.crt, in both the
harness and the workspace provisioner, and git trusts it for the listed URLs
only. This uses http.<url>.sslCAInfo, set through the GIT_CONFIG_COUNT
protocol and appended to any entries the workload's environment already
numbers. It doesn't use GIT_SSL_CAINFO, which replaces git's whole trust
store: a bundle holding only the proxy's CA would break every forge the workload
reaches directly. cloop's own provisioning and write-back git commands run with
a closed environment and import only these keys from it
(http.<https-url>.sslCAInfo and .sslCAPath).
A hub whose git proxy runs under tls: auto needs none of this. Each Pod gets
the hub's own CA from its run's lease Secret, mounted read-only at
/etc/cloop/hub-ca/ca.crt in both containers and trusted the same way, for the
proxy's URL only. If a git_ca_bundle already names the proxy's URL, the bundle
keeps it (with two sslCAInfo values for one URL, git would use whichever came
last), and the hub's CA is delivered for the remaining URLs.
The kubelet fetches the ConfigMap for the Pod, so the executor's Role needs no
rule for it. If the ConfigMap is missing, the Pod stays in ContainerCreating
with an event naming it. config_map without urls is refused, as are URLs
that aren't bare https:// bases, and an unusable section disables the executor
with the reason, as a malformed egress_filter does. The Helm chart renders
this section, and the ConfigMap, from executor.monitorTLS; see
deploy/README.md.
VM-isolated sandboxes: Kata Containers¶
Everything above confines a workload with namespaces, cgroups and seccomp on the host's kernel, so a kernel bug affects the host. Two keys instead run each workload in a lightweight VM with its own kernel, using Kata Containers. An escape then reaches the guest kernel, and the host's kernel is behind a hypervisor. Neither key is set by default, and an empty value keeps the behaviour from before the keys existed.
Locally: executors.container.oci_runtime. runtime: above picks the CLI.
This key picks the low-level runtime the CLI hands each container to, passed as
--runtime <name>:
executors:
container:
enabled: true
runtime: docker # or podman
oci_runtime: kata-qemu # kata | kata-qemu | kata-clh | kata-runtime
image: ghcr.io/blechschmidt/cloop-harness:latest
cpus: 2
memory: 2g
network: none
It must be a registered runtime name, never a path. docker resolves it from
/etc/docker/daemon.json and podman from containers.conf, both root-owned
files you already control, so the name refers to an entry in a trusted table
and the daemon is never told to run an arbitrary binary as root. Names are checked for
form only (letters, digits, ., _, -, no path separator, no leading dash),
because Kata is registered under many different names and a fixed allowlist
would reject working hosts.
A value that fails that check disables the container executor. Unlike the other keys here, it isn't reset to its default, because the default is weaker: falling back to runc would turn the VM sandbox you asked for into a plain container while the executor kept reporting success. A missing executor is a failure you can see.
Kata needs /dev/kvm, and the user cloop runs as must be able to open it.
cloop executor test <id> checks both, and that the CLI knows the runtime name.
See the Kata guide for installation, the
nested-virtualization prerequisite, and what the checks print.
On Kubernetes: executors.kubernetes.runtime_class. This sets
runtimeClassName on every workload Pod, so kube-scheduler places it on a node
that offers that handler:
executors:
kubernetes:
enabled: true
namespace: cloop-workloads
image: ghcr.io/acme/cloop-harness@sha256:…
runtime_class: kata # must already exist in the cluster
node_selector:
katacontainers.io/kata-runtime: "true"
tolerations:
- key: kata
operator: Exists
effect: NoSchedule
In practice you set all three keys together. A RuntimeClass names a handler,
but it doesn't keep ordinary workloads off the Kata nodes, so those nodes are
usually tainted. Pods then need a matching toleration to run there at
all, and a node_selector to be sent there. If you set only runtime_class on
a tainted pool, Pods are never scheduled.
cloop doesn't create the RuntimeClass. It is a cluster-scoped object installed with the Kata node pool, and the hub has no cluster-scoped permissions, so it can't check it in preflight either. The name is checked as an RFC 1123 subdomain at startup, which catches typos before the first run. A valid name for a class that doesn't exist shows up at dispatch, as a Pod that never runs.
The Helm chart's ConfigMap doesn't render runtime_class, node_selector or
tolerations yet, so with a chart-based install you set them in the hub's
config.yaml directly.
Only these keys change. Credentials, resources and deadlines stay as the executor already had them.
In both cases the executor reports the change: cloop executor list appends
hypervisor-backed (and, for the container driver, shows the pair as
docker via kata-qemu), the Executors tab shows a kata / VM chip, and
cloop hub doctor reports virtualized per executor. A workload can also
require it. Placement then refuses any executor that shares the host machine's
kernel, under the constraint virtualization, instead of running the workload
in a weaker sandbox than it asked for. See
Placement.
Sandbox image contract¶
executors.container.image must provide:
- the cloop binary at
/usr/local/bin/cloop; - the agent harness the project's provider needs: the
claudeCLI onPATHfor the defaultclaudecodeprovider, or thecopilotCLI forcopilot. The defaultcloop-harnessimage doesn't includecopilot, so acopilotproject uses the published variant,ghcr.io/blechschmidt/cloop-harness-copilot, or its own image (see the published images); gitand a CA bundle;- a non-root user, since the workload runs as the UID that owns the project directory.
cloop executor test bind-mounts the control plane's own binary read-only at
/usr/local/bin/cloop, so the smoke test works even with an image that doesn't
contain cloop yet.
Per-project images: .cloop/sandbox.yaml¶
executors.container.image is one image for every project on the hub. A project
that needs a different toolchain can override it from its own repository,
without an operator editing this file:
# <project>/.cloop/sandbox.yaml
image: ghcr.io/acme/rust-toolchain:1.79
resources:
cpu: 4
memory: 8g
The spec is committed to the repository, so it is untrusted. It is validated
against a schema, every number is clamped to the same bounds as the keys above,
and it can only make a run more restricted than this file allows: leaving out
capabilities.network removes the network, and no field adds it. A spec asking
for something the bound executor can't do is refused before the run, with the
constraint named.
For the full schema, the restriction rules, per-executor support, and the digest pinning that keeps a run reproducible, see Per-project sandbox.
Image trust: sandbox.image_policy¶
The restriction rules don't cover image: above, because the image is the
sandbox's whole environment. To restrict which images a project may name:
sandbox:
image_policy:
allowed_registries: [ghcr.io] # also "*.example.com" or "*"
allowed_repos: [ghcr.io/acme/*] # optional; empty means any repo there
require_digest: true # refuse a reference pinned to a tag
require_signature: false # cosign; the published hub image bundles it
govern_operator_images: false # also check the operator's own images
Matching uses the parsed reference, so ghcr.io here admits neither
evil.example/ghcr.io/x nor ghcr.io.evil.example/x, and a Unicode lookalike
of it is refused as malformed. An accepted tag is resolved to a digest, and the
digest is what runs. Without the section nothing is restricted, so existing
single-machine installs keep working after an upgrade.
On Kubernetes, the hub resolves the tag at the registry the kubelet pulls from
before it builds the Pod, and the Pod carries the digest. See
registry_secret for the login it
uses with a private registry. There, require_digest and require_signature
also refuse a tag the hub can't resolve, including the executor's own image;
under other policies such a tag runs as a tag, with a warning. The
Helm chart leaves require_digest off, since the
hub pins tags itself, and sets allowed_registries by default to the registry
the hub's own image comes from.
The policy applies to the images a project names. The operator's own images
(executors.container.image, executors.kubernetes.image, a device's or
virtual executor's sandbox image) run as configured, unless
govern_operator_images: true holds them to the same registry, digest and
signature rules. cloop's own images are handled separately: whoever chose them,
they are checked against the workflow that publishes them. See
Image provenance.
For the rules, denial codes, the cosign requirements and the audit record, see Image trust policy.
Resolving image tags for Kubernetes Pods¶
A kubelet acts on a Pod spec later, on whichever node the Pod lands on, and
would resolve a tag in it then. So before building a Pod, the hub resolves every
tag the Pod would carry (the image a project names, executors.kubernetes.image,
or one of cloop's own) to the digest the registry serves at that moment. It
verifies that digest's signature when the policy requires it, and writes
repo@sha256:… into the spec. The run's log starts with each tag and the digest
it resolved to, and the handle, its stored row and the task's task.dispatch
audit row name the digest (pinned_image, image_digest).
A private registry needs a login. The login belongs to the hub, not the workload:
The secret is the config.json that docker login writes: a Docker
configuration whose "auths" say which registry each login is for. Each login
is sent only to its own registry. For an image on another registry, the hub asks
anonymously. The hub opens the secret as its own credential at every dispatch,
so a rotated secret takes effect on the next Pod. It is not granted to the
executor, because a grant to executor:<id> is leased to every run on it and
would put the login in every Pod. Only the shared Docker-configuration form is
accepted: a personal secret is for its owner to use, and a bare user:password
names no registry the hub could limit it to. If the secret can't be read,
resolution fails with an error naming it; the hub doesn't fall back to an
anonymous request. The same login is given to cosign when it fetches the
signature from that registry, in a DOCKER_CONFIG directory private to the hub
(0700) that holds only that login and is removed when the verification ends.
It is never the namespace's image_pull_secrets. Those belong to the kubelet,
for pulling, and the executor's Role grants no access to Secrets.
| The hub couldn't resolve a tag (its registry is unreachable from the hub, refuses, or doesn't know the tag) | |
|---|---|
require_digest or require_signature |
refused, naming the registry and the fix: pin the image by digest, or let the hub reach the registry (with a login in registry_secret if it is private) |
| any other policy | the tag goes into the Pod as written, with a warning in the run's log and the hub's; the kubelet resolves it when it pulls |
cloop executor test kubernetes and cloop hub doctor report registry-login
(which registries the secret covers, or why it can't be read) and
image-resolution (what executors.kubernetes.image resolves to now).
With image_pull_policy: Never, whenever the hub could resolve the tag, a node
must hold the image under the digest the tag resolved to. That means pulled from
the registry, not loaded from a tarball, which records no digest.
Remote executors (edge devices)¶
A remote executor runs work on a machine the control plane can't connect to: an edge device behind NAT, a build machine on an office network, a laptop on hotel Wi-Fi. The device connects out instead and keeps one multiplexed WebSocket open, so you need no inbound port forwarding, VPN or firewall rule.
Enrollment takes two steps, out of band. On the control plane (or in the Executors tab, + Enroll device):
This prints a single-use, expiring token and the command to paste on the
device, including the control plane's certificate pin, which is derived from
ui.tls.cert_file:
cloop executor agent --server wss://cloop.example.com/api/executors/connect \
--token <token> --pin sha256:<base64>
The token is shown once. Only its hash is stored, so it can't be recovered. On
first connect the device exchanges it for a long-lived credential, saves that
with mode 0600 at ~/.cloop/agent.json, and reconnects with it from then on.
If a token leaks, cloop executor revoke <id> (or Revoke on the card)
invalidates it. If it was already redeemed, this also revokes the resulting
credential, drops the device's session, and unbinds every project that used the
device.
--label region=eu (repeatable), or the Labels field of the Enroll dialog,
gives the device labels, which a grant to a label
selector is matched against. They are
stored with the device's credential on the hub. An admin can change them later
with Labels on the device's card (PUT /api/executors/{id}/labels, audited
as executor.labels). When they are saved, a running task holding a credential
from a label grant that the new labels no longer match gives it back. Labels the
device reports itself (cloop executor agent --label) don't count, and match no
grant.
That command runs the agent in the foreground and installs nothing. For a device you want to keep, install the agent as a supervised service:
This writes a hardened systemd unit with Restart=always and the certificate
pin built in, creates a dedicated system user that can't log in, and stores the
token in a 0600 file that the unit refers to by path. The token is never on
the command line, where systemctl show and ps would show it.
--output docker and --output shell cover devices without systemd,
--dry-run shows the unit without writing it, and --uninstall removes it (and
is safe to run more than once). See
Installing the agent as a service.
The Executors panel shows a one-line version of the same thing, served by
GET /install.sh on the hub. That route requires executor.manage and refuses
to answer over plain HTTP, because its output is piped into a root shell on a
device that doesn't yet know which control plane to trust.
Transport security¶
A token proves the device is authorised. It doesn't prove that the server that answered is the control plane, so the agent checks that separately before it sends anything:
- Plaintext is refused.
ws://to a non-loopback host fails at startup. Both the enrollment token and the long-lived credential would travel unencrypted, and since an agent retries forever, one interception would give lasting access.--insecure-transportoverrides this for links protected in some other way, and logs a warning on every connection attempt while it is on. --pin sha256:<base64>requires the server's public key to match the expected one, in addition to normal certificate-chain verification. cloop setsInsecureSkipVerifyon no outbound connection, andtests/security/transport_test.gochecks this across all the code the agent imports.- The pin covers the SPKI (the public key), not the certificate, so a routine renewal that keeps the key doesn't break the fleet. Moving to a new key does. To stage that, pass both pins, comma-separated, until every device has switched over.
- The pin is stored in
~/.cloop/agent.json, so every reconnect for the life of the device is pinned, not only the first. --ca-fileadds a PEM bundle to the trusted roots. This is how you reach a server with a private CA or acloop hub tls-initcertificate. No flag turns verification off.
Read the current pin at any time with cloop hub pin.
The agent endpoint isn't behind the dashboard's token or OIDC authentication. Agents aren't users: each has its own scoped credential that can be revoked individually, instead of one that would grant full UI access. The endpoint is still behind the rate limiter, which absorbs floods of unauthenticated connection attempts.
Scoped network egress¶
A sandbox with network: none is safe but can't fetch a dependency, clone a
repository or call an API. Instead of giving it a full network, cloop egress
lets it use the control plane's connection, one destination at a time.
The sandbox has no route of its own. It reaches the outside world only through an authenticated forward proxy hosted by the control plane, which decides on each connection:
cloop egress grant --to project:/srv/app \
--hosts 'api.github.com,*.githubusercontent.com' --ports 443 \
--max-down 500m --ttl 8h
cloop egress list # who may reach what, until when
cloop egress test https://api.github.com/rate_limit # ...and does it actually work?
cloop egress revoke egress_2f1c… # withdraw, closing live tunnels
Turn the proxy on under executors.egress:
executors:
egress:
enabled: true
listen_addr: "0.0.0.0:8899" # every interface: a container reaches it at its bridge's gateway
advertise_addr: "203.0.113.5:8899" # what a Pod or an edge device is pointed at
max_session_minutes: 15 # one session period; renewed while the run lives
dial_timeout_seconds: 15
default_max_bytes_down: 1g # quota for a grant that names none
| Key | Meaning |
|---|---|
enabled |
cloop ui binds listen_addr at startup and hosts the proxy. Off by default |
listen_addr |
Bind address. Empty binds an ephemeral loopback port, which only host-process runs can reach. Container sandboxes need 0.0.0.0:<port> (or the bridge's own address). On every interface, the per-session credential is what protects the proxy |
advertise_addr |
The address a Pod or an edge device connects to. A bare host gets the bound port. Not used for the hub's own containers, which connect to their bridge's gateway |
max_session_minutes |
One session period, 15 by default, at most 240. A session is renewed while its run lives, but never past its grant's expiry |
dial_timeout_seconds |
Time limit for the proxy's connection to an authorised origin |
default_max_bytes_up / default_max_bytes_down |
The per-session quota for a grant that names none. A grant's own quota takes precedence |
What the hub does with it. cloop ui binds the proxy when it starts, before
anything can be dispatched, and logs egress proxy listening on X, advertised as Y.
cloop hub doctor reports the same line for every running hub, or why its proxy
couldn't bind (egress.hosted). A proxy that fails to bind doesn't stop the hub.
Every member of a hub cluster binds its own
proxy, because a session lives in the memory of the member that issued it, and
the member that dispatches a run issues its session. So in a cluster,
listen_addr usually comes from each member's
per-instance overlay or uses port 0.
cloop serve hosts no proxy (it dispatches nothing to a sandbox), and neither
does cloop run on a laptop or an executor agent on a device; a device's
sandboxes use the proxy of the hub that dispatched to them. cloop egress test
runs its own proxy for the length of one test.
A session is never shared between processes, and its token is never stored,
but a record of the session is: the token's SHA-256, the grant snapshot, the
deadline and the byte counters, saved in proxy_sessions every minute. So a
run that outlives the
process that issued its session (the hub restarted, or another cluster member
took over the run after the dispatching member died) gets its session back. The
process that adopts the run re-reads the grant and restores the session under
the same credential, continuing from the saved totals against the same quota,
never a larger one. If the grant leaves the quota to the defaults above, the
restored quota is the smaller of the one the session was redeemed with and the
current one, and the deadline is capped at the session ceiling, counted from
the restore.
A hub that is stopped cleanly saves and suspends each run's session instead of
closing it; after a SIGKILL, at most a minute of traffic can be missing from the
count. A session whose grant was revoked or expired in the meantime isn't
restored, and its proxy requests are refused (407) from then on. If a session
couldn't be restored for a reason that may pass, such as an unreadable grant
store, the restore is tried again after two seconds, then with the wait doubling
up to a minute, and on its next request, until it succeeds or its recorded
expiry passes. Meanwhile the run's history and cloop hub doctor report it. A
request made before the run is adopted waits up to 60 seconds for the restore.
The adopting process's proxy serves the restored session at the address the run
was given, so advertise_addr must reach that process. On a single hub, that is
the address the restarted process binds again. Egress isn't forwarded between
cluster members, so a session is restored only from a member that is gone. When
a run moves between two live members, the old member keeps serving its egress
session until the run ends (it learns this from the run's owner row) and then
ends the session. Meanwhile the run's owner row keeps naming the session, and
the member that owns the run takes it over if the old member stops first.
One session per run. When a run is dispatched (a task run, a helper
subcommand such as cloop suggest, or cloop task reproduce) and its project
holds an active grant aimed at it (project:, executor: or any; a feature
uses its parent project's grants, never grants for its own path), the hub
redeems one session for it. The session is issued to the run's user and run id,
which every audit row it produces carries. Its quotas are the grant's, or the
defaults above. It lasts one session period and is renewed while the run lives,
never past the grant's expiry. It is closed when the run ends, when the run is
stopped, and when the hub shuts down. Revoking the grant (in the dashboard, with
cloop egress revoke, or on another hub member) closes the session within a
minute (at once through the dashboard of the hub holding it) and cuts its open
tunnels. The project's event journal records each of these as an egress row.
It also records why a project that holds a grant got no session: the hub has no
proxy, the grant is used up, the run has no network, or the executor has no
route to the proxy. The journal always gives the reason, and never contains a
credential.
What the workload receives. HTTPS_PROXY, HTTP_PROXY, their lowercase
forms, and NO_PROXY (loopback plus the hub's own git proxy and Kubernetes
monitor, which git and kubectl would otherwise send through the egress proxy),
along with CLOOP_EGRESS_SESSION, CLOOP_EGRESS_ALLOW,
CLOOP_EGRESS_GRANT_ID and CLOOP_EGRESS_EXPIRES (the deadline when the
session was issued; renewals move the real deadline). The proxy variables
contain the session's credential, so they are marked sensitive: they are
scrubbed from the run's live log, artifacts, transcript and provider call log,
left out of the dispatch record the hub stores, and never appear in a journal or
audit row. A harness that honours HTTPS_PROXY (Claude Code does, as do git,
curl, and Go's and Node's HTTP clients) sends all of its HTTP through the proxy,
including its own model API calls. So a grant for a sandbox with no other way
out must include the model API's host (api.anthropic.com).
How each executor reaches it.
| Executor | Address in HTTPS_PROXY |
Needs |
|---|---|---|
| host process | the bound address (127.0.0.1 for a wildcard bind) |
— |
| container (the hub's engine) | host.containers.internal, pinned in the sandbox to the gateway of the bridge it joins, including an --internal bridge |
listen_addr on every interface, or on that bridge's address. A loopback bind is refused, with an error naming the fix |
| Kubernetes | advertise_addr (a Service) |
the Pod's NetworkPolicy must already allow it when egress_filter or stored rules apply; the hub checks this before issuing the session |
| remote agent, virtual executor | advertise_addr |
an address the device can reach. Loopback, link-local, host.docker.internal/host.containers.internal and cluster-internal names are refused, with a journal row. Behind a device firewall it must be an IP address, and the agent must speak protocol v18 |
Firewalls open the proxy and nothing else. When the container executor
installs an nftables ruleset for a sandbox (its own egress_filter, or a
device or project firewall), the driver adds the
proxy's address and port (the bridge gateway, TCP, that port only) to the
compiled allow set. Before installing anything, it checks with the policy's own
Evaluate that the sandbox can reach the proxy. An operator deny list that
covers the proxy still takes precedence, and then the run is refused instead of
starting with a session it can't use. An --internal bridge with no ruleset
needs no opening, because its gateway is on-link. A v18 agent does the same on a
device.
.cloop/sandbox.yaml. A spec that names a grant in capabilities.network
asks for that grant's session. If the hub can't provide it (no proxy, a proxy
that failed to bind, or no route from the bound executor), the run is refused
with a 409 (sandbox_egress_unavailable) and the fix, instead of starting
without the egress its author asked for. A spec that names no grant removes the
network, so its runs get no session.
A grant has hosts, CIDRs, ports, methods, byte quotas and a TTL, and is targeted
with the same --to syntax as cloop secret grant. Redeeming it creates a
single-use, per-session credential (256 random bits, compared in constant
time, stored only as a SHA-256), which the workload receives in
HTTPS_PROXY / HTTP_PROXY / NO_PROXY. The workload never sees the grant
itself.
What the proxy enforces:
- Host, port and method are checked before any data is sent. Methods apply to plain HTTP only, because a CONNECT tunnel's method is inside TLS and can't be seen (see below).
- Resolve-once pinning. The destination name is resolved once, every returned address must pass the policy, and the connection goes to the resolved address. There is no second lookup that a hostile DNS server could answer differently, so DNS rebinding doesn't work.
- SSRF hard-block. Loopback, RFC1918, CGNAT, link-local (where the cloud
metadata service at
169.254.169.254lives), multicast and unspecified addresses are refused, even under--hosts '*'. To reach one, you must name its range in--cidrs, so an internal destination is always allowed explicitly. IPv4-in-IPv6 encodings (::ffff:127.0.0.1, NAT64,::127.0.0.1) are unwrapped before the check. - Quotas, mid-stream. A transfer that goes over quota is cut off while in progress.
- TTL, mid-tunnel. An open tunnel, even an idle one, ends when its session does.
Every decision, allow or deny, is written to the same hash-chained audit log as the credential broker's, with the identity, task, host, port, byte counts and verdict, and no credential material.
The tunnel needs no CA. cloop doesn't terminate, inspect or re-sign TLS, and installs no certificate in the sandbox. The workload validates the origin's certificate itself, as it would without a proxy. The cost is that cloop can't see inside tunnelled requests, but a proxy that could read every sandbox's traffic would be a much more valuable target than the credentials it protects.
Revocation takes effect immediately, not at the TTL. That is the advantage of brokering access instead of handing over a token: a leaked PAT can't be taken back from a running container, but the control plane can end a proxy session at the proxy, even mid-tunnel.
The proxy can't restrict a workload that doesn't use it. Every check above
applies only to traffic the harness sends through $HTTP_PROXY. Combine it with
egress_filter, ideally with internal: true,
where the proxy is the only address the sandbox can reach, so the allowlist
applies to all traffic.
Git interception proxy¶
A sandbox that clones the project and pushes its work back needs a forge
credential, and the rule that keeps its pushes inside cloop/ branches runs in
code the sandbox itself executes, so the sandbox could ignore it. With
executors.git_proxy, the hub keeps the PAT, runs a git smart-HTTP proxy, and
gives the sandbox a session token that works for one repository under one
policy. The proxy parses each push's list of ref updates and refuses anything
outside the allowlist before it presents the credential upstream.
executors:
git_proxy:
enabled: true
tls: auto # the hub issues the certificate
listen_addr: "0.0.0.0:8443" # where it binds
advertise_url: "https://hub.internal:8443" # what the SANDBOX connects to
min_tls_version: "1.2" # or "1.3"
session_minutes: 60 # 0 means 60; ceiling is 720
allowed_refs: ["refs/heads/cloop/**"] # the default
allow_delete: false
push_scan: values # the default; shapes or off
push_scan_max_object_mb: 1024 # 0 means 1024; ceiling 65536
push_scan_max_push_mb: 4096 # 0 means 4096; ceiling 1048576
push_scan_seconds: 300 # 0 means 300; ceiling 3600
cloop hub bootstrap writes the first eight keys, deriving advertise_url from
--external-url. The push scan keys keep their defaults.
- Off by default. The proxy changes the URL sandboxes push to, so the
operator turns it on; an upgrade doesn't. With it off, workspaces are
provisioned as before, and a GitHub grant delivers the forge credential itself
into the sandbox. On a hub with
allow_host_process: false,cloop hub doctorreports this asgitproxy.forge_credentials. - TLS is required, because the session token is sent with every request, and the certificate is validated by the sandbox's git, not by the hub. There are two ways to provide it:
tls: autohas the hub issue the certificate. A CA is created the first time any hub process serving this control plane starts a monitor undertls: auto. Its private key is sealed under the KEK registry in the control-plane database (never stored as a separate file), so every hub-cluster member serves the same identity, a database backup includes it, andcloop hub key rotatere-wraps it. The proxy serves a leaf certificate from that CA naming the host ofadvertise_urland oflisten_addr(when it names one), valid for 72 hours. The cluster leader replaces it when 48 hours are left, and every member serves the new one within a minute, per handshake, so a push in progress isn't interrupted. Every sandbox the hub dispatches is told to trust the CA foradvertise_urlonly, throughhttp.<advertise_url>.sslCAInfoin theGIT_CONFIG_COUNTblock. It never usesGIT_SSL_CAINFO, which would replace the image's trust store and trust every host the CA signed. No image needs the CA built in: a host process and a container get a read-only file, a Pod gets it from its run's lease Secret, and an edge device gets it in the start frame (agents speaking protocol v21 or later). An older agent is refused any workload that would talk to the proxy, with an error naming the upgrade, instead of being given the forge credential. NeedsCLOOP_SECRET_KEY; can't be combined withcert_fileandkey_file.cert_fileandkey_file, a certificate and key the operator provides. A self-signed certificate needs its CA in the sandbox image, or on Kubernetes inexecutors.kubernetes.git_ca_bundle.
tls: auto must be set explicitly; it is never assumed. An enabled section
with neither tls: auto nor certificate files is switched off at load, as in
older versions, so an upgrade never starts a proxy by itself. tls: auto
together with cert_file/key_file is refused by cloop config validate,
and at load the files are used and tls: auto is dropped.
- advertise_url must be reachable from where git runs: a Kubernetes
Service for the Pod backend, the hub's address on the local network for an
edge device, or host.docker.internal / host.containers.internal for a
containerised agent. If it is empty, the bound address is used, which works
only when that process shares the hub's network namespace.
- A workspace session lasts as long as its run. It is created for
session_minutes, renewed by the same amount while the run lives, and closed
once the run is done with it, so a run longer than session_minutes can still
push its work back. A session that a GitHub lease creates for the workload's
own git lasts at most session_minutes from dispatch.
- allowed_refs is the upper limit for every grant. A GitHub grant can
narrow it to particular branches (--branches, or Push to branches in the
dashboard) but never widen it. A hub that wants projects to push to branches
of their own choosing sets it wider, to ["refs/heads/**"], and lets each
grant say which. The proxy is also the only thing that can enforce a grant's
branch list: without it, such a grant is delivered read-only (github_app) or
not at all (github_pat). See
limiting pushes to particular branches.
- Every push is scanned for the credentials its run was given before the
forge can accept it. push_scan: values looks for the exact values the run's
lease and spec delivered (env values, kubeconfig tokens and client keys,
registry auth, egress and model relay credentials, GitHub tokens), as
delivered and base64- and percent-encoded. shapes also looks for
pkg/redact's credential formats. off forwards packs without reading them. A
push that contains a credential is refused with no ref moved, and the error
names the objects, the grant and the secret, never the value. A project's own
.cloop/config.yaml can set a stricter push_scan, never a weaker one. A
push that inflates past push_scan_max_object_mb or push_scan_max_push_mb,
or needs more than push_scan_seconds of scanning (time spent waiting on the
network doesn't count), is refused with an error naming the setting, because a
scan that gave up hasn't shown the push is clean. An unreadable push_scan
loads as values, never as off. See
the push scan.
- Every decision is written to the hash-chained audit log as
gitproxy.push_denied, gitproxy.push_secret_refused,
gitproxy.push_allowed, gitproxy.session_minted,
gitproxy.session_renewed, gitproxy.session_closed, gitproxy.fetch or
gitproxy.rejected. Alert on the first two. Under tls: auto,
hub_ca.minted and hub_ca.leaf_issued record the CA and every certificate
issued from it.
The proxy runs inside the hub process, because sessions are held in memory and the process that creates them must also serve them. There is no standalone command. For the full design and operations, see git interception proxy.
Kubernetes access monitor¶
A kubeconfig grant is delivered by rewriting the kubeconfig: contexts outside
--contexts are dropped, along with the clusters and users nothing references
any more. That controls which clusters the sandbox can reach, and nothing else.
The namespace in a delivered context is only a client-side default
(kubectl -n kube-system get secrets ignores it), and a kubeconfig has no way
to express read-only access. Its credential has whatever permissions the
cluster's RBAC gives it, which for a kubeconfig a developer uploaded is often
cluster-admin.
With executors.kube_guard, the hub keeps the cluster credential, runs an API
interception proxy, and gives the sandbox a kubeconfig that points at the
monitor, with a short-lived bearer token. Every request is parsed into verb,
group, resource, subresource, namespace and name, and checked against the grant
before the credential is attached.
executors:
kube_guard:
enabled: true
tls: auto # the hub issues the certificate
listen_addr: "0.0.0.0:8444" # where it binds
advertise_url: "https://hub.internal:8444" # what the SANDBOX connects to
# or, instead of tls: auto:
# cert_file: /etc/cloop/tls/kube-guard.crt
# key_file: /etc/cloop/tls/kube-guard.key
# ca_file: /etc/cloop/tls/kube-guard-ca.pem # empty falls back to cert_file
min_tls_version: "1.2" # or "1.3"
session_minutes: 60 # 0 means 60; ceiling is 720
verbs: [get, list, watch] # a hub-wide ceiling; omit to let grants decide
namespaces: ["team-*"]
resources: ["pods", "configmaps", "apps/deployments"]
audit_allowed: false # true: a row per forwarded request too
- Off by default. The monitor changes the server a sandbox's
kubectltalks to, so the operator turns it on; an upgrade doesn't. With it off, kubeconfigs are minimised and delivered as before. - TLS is required. The session token is sent in an
Authorizationheader on every request.tls: autohas the hub issue the certificate from the same CA as the git proxy's, for the host ofadvertise_urland oflisten_addr, and embed the CA in every kubeconfig the monitor issues. It needsCLOOP_SECRET_KEYand can't be combined withcert_file,key_fileandca_file. Withouttls: auto, an enabled section that lackscert_fileorkey_fileis switched off at load instead of sending tokens unencrypted. Either way the sandbox image needs no change: the hub's CA, orca_file(elsecert_file), is embedded in the delivered kubeconfig ascertificate-authority-data. Only that kubeconfig'skubectltrusts it, for that server; nothing else in the sandbox does. advertise_urlmust be reachable from wherekubectlruns, and must be a barehttps://base with no path, because it becomes the kubeconfig'sserver:. If it is empty, the bound address is used, which works only when the sandbox shares the hub's network namespace.verbsis a hub-wide upper limit, not a grant, and is intersected with each grant's own verbs. Empty means no limit: each grant decides, and a grant that names no verbs is read-only. Setting it to[get, list, watch]means no project on this hub can write to a cluster, whatever a grant asks for.namespacesandresourcesare intersected the same way. If the intersection for any of the three is empty, the lease is refused and no session is created, because an empty namespace list would otherwise mean "no restriction", and narrowing would widen access. Write these as a literal superset of every grant, or leave them empty. See the floor.exec,attach,portforwardandproxyare refused for every verb, and no policy can re-enable them:GET .../pods/x/execopens a shell, which is not a read. Protocol upgrades are refused; ordinary watches still work.- A hub that can't start the monitor refuses kubeconfig leases instead of falling back to delivering the cluster credential. Everything else keeps working.
- Every decision is written to the hash-chained audit log as
kubeguard.request_denied,kubeguard.session_minted,kubeguard.session_closedorkubeguard.rejected, and counted bycloop_kubeguard_requests_totalandcloop_kubeguard_denials_total. Alert on the first. audit_allowedadds akubeguard.request_allowedrow for every forwarded request. It is off by default, because onekubectl get podssends a burst of discovery requests and a watch never ends, so those rows would bury the denials. Turn it on if the audit trail must show what a sandbox read from the cluster.
Like the git proxy, it runs inside the hub process and has no standalone command. For the full design and operations, see Kubernetes access monitor.
Hardened (enterprise) configuration¶
By default cloop may run workloads as child processes of the control plane. To make sure the web UI never starts a harness on the host, turn host execution off:
executors:
allow_host_process: false # strict no-host-execution mode
container:
enabled: true # …and give it somewhere else to run
sandbox:
image_policy: # …and constrain what "somewhere else" may be
allowed_registries: [ghcr.io]
require_digest: true
or run cloop config set executors.allow_host_process false.
Set the image policy at the same time. If execution moves off the host but a committed file can still choose the image, a pull request still decides what runs inside the container.
A hosted deployment must change this setting. If it is absent, it counts as
true, so existing single-machine installs keep working after an upgrade.
With it false:
- the
localprocessdriver refuses to start anything; executor.Resolverefuses to return that driver, so background code paths are covered too, not only those with an explicit check;- every web UI path that would have dispatched work returns HTTP 409 with a
machine-readable
code: host_execution_deniedand aremediationstring naming the executors that are available; and - the Executors tab shows the mode in a banner.
You can harden a hub before any device has enrolled. Remote executors are added at runtime, not through this file, so the config still loads, with a warning, instead of stopping the hub from starting.
Hardening also means controlling who can reach the hub. A hub without sign-in
listens on 127.0.0.1 only, and refuses an address beyond loopback unless
ui.allow_unauthenticated_network is set, which a hosted deployment should
never do. Configure ui.oidc before the hub listens on a network. Behind a TLS
proxy on the same host, set ui.listen: 127.0.0.1 so the plaintext port can be
reached only through the proxy. cloop hub doctor checks both (ui.exposure);
see Web UI.
The host sandbox¶
On a hub with single sign-on that still allows host execution, the people it serves run on one machine as one operating-system user. The host sandbox keeps each person's runs and model calls there to their own project and Claude login; see the host sandbox for exactly what a sandboxed run can open and how Linux enforces it.
executors:
host_sandbox:
mode: auto # auto (default) | all | off
read_only: # optional: further paths every sandboxed run may read
- /srv/toolchains/jdk-21
pass_env: # optional: more of the hub's environment to pass on
- PIP_INDEX_URL
| Key | Meaning |
|---|---|
mode |
auto: on a hub with sign-in, every run on the hub's host for a person who is not a hub administrator is sandboxed, and an administrator's run too on a project such a person may write. all: every administrator's run and the hub's own login's too. off: none, which cloop hub doctor fails on a hub with sign-in. Unset is auto; any other value stops the hub from starting. |
read_only |
Absolute paths every sandboxed run may read and execute, for a toolchain installed outside /usr (/opt and /snap are not granted by default). A path that would expose the hub's files or another person's (the filesystem's root, the hub's directory, the identity root, another project, or a directory above one of them) is left out at dispatch, and the doctor names it. |
pass_env |
Names of variables in the hub's own environment that a sandboxed run inherits as well as the default allow-list (PATH, the locale, the proxies and CA bundles, the Go toolchain's and the harness's settings). A name here is passed even if it holds a credential, so list only what every sandboxed run may have. |
The sandbox needs Linux 6.2 or later with Landlock enabled (landlock in the
kernel's lsm= list, the default on current Ubuntu and Fedora). On a hub that
runs as root it also creates mount, PID and IPC namespaces, which a hub in an
unprivileged container may not be allowed to do. Where the sandbox is needed
and unavailable, the hub refuses the run with 409 rather than run it
unsandboxed. A project whose .git is a file pointing at a repository outside
the project directory cannot use that repository from inside the sandbox.
A sandboxed run's HOME is sandbox-home inside its person's Claude login
directory. It starts empty, so git needs an identity
(git config --global user.email … from a run, or the project's own config)
and tools fill their caches there once per person. Credentials reach it the way
they reach a container: through grants, never through the hub user's
~/.ssh or dotfiles.
Every workload the host driver starts, sandboxed or not, starts without the
hub's own credentials in its environment: CLOOP_SECRET_KEY and the other root
key variables, CREDENTIALS_DIRECTORY, CLOOP_UI_TOKEN and the
CLOOP_NOTIFY_* webhook URLs, and CLOOP_OIDC_CLIENT_SECRET. A sandboxed
run inherits only the allow-list above and pass_env, so not the hub's own
OPENAI_API_KEY or GITHUB_TOKEN: a person's run gets its credentials through
grants. A person's local_repo grant may not name the hub's directory, the
identity root or another person's project, on any executor.
Minimum agent build¶
The oldest cloop build a remote executor agent can run and still receive work, for the whole fleet:
By default it is absent, which means there is no minimum, so existing deployments place work as before.
The negotiated protocol version doesn't capture most changes between builds. It changes only when a message frame changes, so two devices can speak the current protocol and still differ by a year of fixes to things that never cross the wire. With a minimum, you can stop scheduling onto devices that lack a fix you have deployed, instead of finding out which ones they are from failures.
A device below the minimum is refused at placement, under the agent_build
constraint, with a message naming both versions and the upgrade command, instead
of being selected and then failing at run time. A device that can't show it meets
the minimum (it reports no build, the legacy 1 placeholder, or an unreleased
dev+g… build) is refused too, with a message saying which. Container,
Kubernetes and local-process executors are never checked: they run the hub's
own binary and have no separate build to compare.
The value must be a vMAJOR.MINOR.PATCH release that can be compared.
cloop config set refuses anything else. A bad value in a hand-edited
config.yaml doesn't stop the hub from starting, but the Executors tab then
shows a banner saying no floor is being enforced, so you know the minimum
isn't in effect.
Like allow_host_process, it is applied as a ratchet: a hub reads many
tenants' config.yaml files, and a file a tenant controls must not be able to
lower a fleet-wide minimum. To lower it, restart with the looser config.
See Placement for how the constraint is ranked and enforced when scheduling and when binding projects.
Isolation evidence¶
What placement does with an executor whose isolation claim has never been verified:
An executor's Virtualized and KernelIsolated capabilities come from its
runtime's name: kata claims a VM, runsc a userspace kernel. The hub checks
the claim by running cloop isolation-probe under the runtime when configured
executors are reconciled, at each device's hello, after a runtime change, daily,
and when you press Re-probe on the card. It records what the kernel serving
the sandbox reported about itself, compared with the host's own kernel.
| Evidence | A project requiring kernel isolation or virtualization |
|---|---|
| verifies the claim | is placed |
contradicts it (a kata that is runc or gVisor, a runsc that is runc) |
is refused under the isolation_evidence constraint, naming the evidence, whatever this key says |
| none yet, or the probe couldn't run or was inconclusive | warn: placed, with a warning in the run's journal; required: refused |
warn and required are the only values. cloop config set refuses anything
else, and an unknown value in a hand-edited file shows a banner on the
Executors tab saying unverified executors are being admitted. Like
min_agent_build, it is a ratchet: once a hub requires evidence, a tenant's
config.yaml saying warn doesn't relax it. It is a hub-scope key, so the
per-instance overlay config.ui-<port>.yaml can set it for one hub only.
Before turning on required, check the Executors panel or cloop hub doctor
(executors.isolation). A device whose agent predates protocol v26 can't be
probed, so every project requiring isolation on it would be refused until the
agent is upgraded. See The name is checked.
Image provenance¶
What a dispatch does with one of cloop's own images whose signature it couldn't verify:
cloop's published images (ghcr.io/blechschmidt/cloop, cloop-harness and
cloop-harness-copilot) are signed keyless by
.github/workflows/publish-images.yml, for every digest it pushes. The hub
checks one of them before a sandbox runs it, however it was chosen: as the
default image, configured for an executor, as a device's or virtual executor's
sandbox image, or named by a project. The tag is resolved to a digest (from the
local image store after the pull on the container executor, and at the registry
for a device or a Kubernetes Pod). cosign verifies the digest against that
workflow's identity on main or a release tag, and the dispatch names the
digest.
| cosign finds | warn (default) |
required |
|---|---|---|
| the workflow's signature | runs the verified digest | runs the verified digest |
| a signature from anyone else, or bytes that don't match | refused | refused |
| no signature (an image published before signing began, or pushed unsigned) | runs, with a warning on the run and in cloop hub doctor |
refused |
| no check possible: no cosign on the hub, the registry or Sigstore unreachable, or an image built locally | runs, with the warning | refused |
On Kubernetes the Pod carries the digest that was checked, whether or not it
verified, so a kubelet pulling later gets that same image. A tag the hub
couldn't resolve runs as the tag, with the warning, unless required refuses
it, or require_digest or require_signature is set (both send no tag to a
Pod). On a device, the start frame names the verified digest, and the device's
engine never pulls: a device with an older copy of the tag refuses to start,
naming docker pull <digest>. For an image that couldn't be verified, the
device uses its own copy of the tag, with the warning.
The published hub image includes cosign (v3.1.4, pinned by checksum); elsewhere,
install it on the hub's PATH. cloop hub verify-image <image> runs the check
by hand, and cloop hub doctor reports it as images.provenance: whether
cosign is installed and its version, the setting, and what each image in use
resolved to. Before turning on required, check that the images in use are
signed. Images published before signing began are not: pull :edge, or a
release after v0.0.7. An organisation that rebuilds and signs the images itself
can point the check at its own signer with CLOOP_PROVENANCE_IMAGES_ISSUER and
CLOOP_PROVENANCE_IMAGES_IDENTITY. These are separate from the release and edge
pins, which they don't change.
Like isolation_evidence, it is a ratchet: once a hub requires verified
images, a tenant's config.yaml saying warn doesn't relax it.
cloop config set refuses any value other than warn and required. It is a
hub-scope key, so the
per-instance overlay config.ui-<port>.yaml can set it for one hub only.
Feature bundles¶
A feature that runs on an executor isolated from the hub (a container, a remote device, a virtual executor) is sent there as a git bundle of its branch, and its run's commits come back as another bundle. This setting caps the size of both:
The hub applies the cap when it bundles the branch (falling back to the newest
50 commits, then the newest one, before refusing), the sandbox applies it when
it bundles its own work, and the hub applies it again when that work arrives.
The error says which bundle was too large and names this setting. Values outside
0..128 are refused when the configuration is loaded; 0 means the default.
Failover¶
When an executor stops responding while it holds a run, the hub's supervisor restarts the run on another executor. This setting limits how many times one dispatch can be restarted elsewhere:
Past the cap, the run isn't dispatched again: its session closes as
failover_exhausted, and the tasks it was running fail, with a message naming
every node the run was lost on and when each became unreachable. Separately
from the cap, a task that two or more different executors went down under is
quarantined as a suspected node killer. Its run isn't dispatched again, and the
task runs again only after an explicit reset (cloop task reset <id>, or a
reset to pending from the dashboard). cloop executor list --inventory lists
the quarantined tasks.
A replacement is dispatched like any run and tracked as the project's run. It
never goes to an executor restricted to an access list. When no replacement is
possible, the project is paused with reason executor_lost, naming the
executor.
This is a hub-scope key, read at every failover, so a per-instance overlay can
set it for one hub only, and a change applies without a restart.
cloop config set refuses a value outside 0..10. Such a value written into
the file by hand is reset to the default when the configuration is loaded, with
a warning; it never means "no cap". See
the failover cap.
Returned work held in memory¶
A remote executor sends a run's work back as a write-back bundle, streamed in chunks, and a seeded run's project state as one document. The hub holds both in memory until it collects them. Each is capped per run (a bundle at the cap its dispatch asked for, a document at 640 KiB), and these two settings cap the total:
executors:
remote:
max_pinned_writeback_bytes: 268435456 # per executor; the default, 256 MiB
max_pinned_writeback_total_bytes: 1073741824 # every executor together; the default, 1 GiB
Both are byte counts, not megabytes. Values outside 1 MiB to 64 GiB are refused
by cloop config set and cloop config validate, and such a value written into
the file by hand is reset to its default when the configuration is loaded, with
a warning; it never means "unbounded". A per-executor budget above the ceiling
is refused too, and lowered to the ceiling at load.
The first is how much one device can make the hub hold, across all the runs it
is returning work for. The second is how much the whole fleet can, and keeps
the hub serving everyone else when several devices misbehave at once. A frame
that would exceed either fails that run's write-back: none of it is kept, the
run's journal names the limit and how much was already held, and work held for
other runs isn't affected. Held work is released as soon as it is collected,
when its run ends without a result that would make it collectable, when the
device is revoked or removed, and fifteen minutes after its run ended if
nothing has collected it. cloop_writeback_pinned_bytes and
cloop_writeback_pinned_bytes_max report the current level; see
the metrics.
Keep executors.remote.max_pinned_writeback_bytes at least as large as
executors.feature_bundle_mb, or a feature run's work is refused when it
arrives; the Executors panel warns when it isn't. These are hub-scope keys,
read when the hub starts its agent endpoint, so a per-instance overlay can set
them for one hub only, and a change takes effect at the next restart. In a hub
cluster, each member applies them to the memory it holds itself. See
what a device's returned work may hold.
Pushing a run's work back¶
A run on an executor that doesn't share the hub's filesystem (a remote device, a Pod) works in a clone of the project's repository there. By default its changes stay there, unless the run's own git pushes them. This setting makes the executor push them back:
Once a run's workload exits successfully, the executor commits everything the
run changed in the checkout (as git sees it, so .gitignore applies) to a new
branch cloop/run-<UTC time>-<random>, and pushes it to the project's origin
with the workspace's credential. When the
git proxy runs, the push goes through the
proxy's pinned session for the workspace, so the proxy's ref policy decides it,
and a write-back after a hub restart is served by the
restored session. The
project's journal records the branch and commit, or why the push failed, as a
write_back row.
It applies to a project whose checkout on the hub has an https origin and a
GitHub grant that covers it, on an executor that can return work (a device
whose agent has git). The workspace is fetched at the commit the hub's checkout
records for origin/<branch>, which is the base the push is measured from, so
fetch the origin on the hub before a run. A project with no such ref, or no
branch checked out, is dispatched as before, without a write-back, and its
journal says why. The push uses the workspace's git proxy session, which is
renewed while the run lives and closed once its write-back has run, so a run
longer than executors.git_proxy.session_minutes can still push its work back.
A value other than push is refused by cloop config validate and
cloop config set. Such a value written into the file by hand is treated as
unset and shown as a warning on the Executors tab. The setting is read from the
hub's own configuration, including its instance overlay, on every dispatch.
It is a hub-scope key: read once at startup, from config.yaml with the hub's
overlay merged in.
Executors that bring their own Claude credential¶
A claudecode, anthropic or copilot run on an executor isolated from the
hub gets its harness credential (a Claude credential, or a Copilot one for
copilot) only through a secret grant. The hub refuses to dispatch a run whose
lease would carry none (harness_credential_missing; see the Claude
credential of a sandboxed run).
If an executor supplies the credential itself (a device whose agent user is
signed in to claude, an image with a key in its environment, a cloud role
behind Bedrock or Vertex), list it here to skip the check for it:
It skips nothing on the hub's own host. There, a copilot project on a hub with
single sign-on needs a credential for the person each run acts for, because the
only credential the host could supply is the hub's own (per-person
Copilot credentials).
Only the check is skipped. The grants such a run holds are delivered as before,
and another user's personal Claude credential is still withheld from it. Only
the hub's own configuration can set this list, because a project's
config.yaml is written by whoever can push to the project. An entry that is
empty or contains whitespace is refused when the configuration is loaded.
The model relay for sandboxed runs¶
A claudecode or anthropic run on an executor isolated from the hub (a
container, a Pod, an enrolled device, a virtual executor) gets its Claude
credential as a leased environment variable. The workload then holds the
plaintext credential for the whole run: it can print it, commit it or send it
anywhere, revoking it doesn't remove it from a running device task, and the hub
charges the run's budgets whatever the run reports it spent.
executors.model_relay keeps the credential on the hub instead:
executors:
git_proxy:
enabled: true
tls: auto # the relay rides this listener by default
model_relay:
enabled: true
# listener: git_proxy # the default; or dashboard
# models: ["claude-sonnet-*", "claude-haiku-*"] # default: every Claude model
# max_output_tokens: 32000 # 0 means 32000
# upstream_base_url: https://api.anthropic.com
# hub_credential: ["project:/srv/app", "executor:build-box"]
With it on, each such dispatch creates a relay session bound to the run's
secret lease. The sandbox gets ANTHROPIC_BASE_URL pointing at the relay and a
cloop_relay_… session token in ANTHROPIC_AUTH_TOKEN (ANTHROPIC_API_KEY,
with the URL ending in /v1, for the anthropic provider). Every Claude
credential the lease delivered is removed from the sandbox's environment, and
the hub uses it upstream. The session lasts as long as the lease. It is recorded
for the hub process that adopts the run after a restart, renewed by the lease's
keepalive, and ended at once when the lease is released or revoked or its grant
is withdrawn; the run's next model call is then refused with the reason. Any hub
member can serve it. The usage in each response is read from the API's own
counts as it streams through, and charged to the identity that started the run,
in the same daily_token_budget and daily_cost_usd counters that
quotas enforce. A call that
would exceed one of them is refused before it reaches the API. See the Claude
credential of a sandboxed
run
for what a run and its card show.
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Sends the model calls of every claudecode and anthropic run on an isolating executor through the relay. Off by default, because it changes the endpoint every sandboxed harness talks to; the operator decides whether to turn it on. |
listener |
git_proxy |
Where sandboxes reach it. git_proxy serves it on the git interception proxy's listener at <advertise_url>/_anthropic, so executors.git_proxy must be on; under tls: auto the harness gets the hub's CA in a separate file through NODE_EXTRA_CA_CERTS. dashboard serves it on the hub's own listener at <ui.external_url>/_anthropic, which must be https with a certificate the sandbox already trusts; nothing is added to the sandbox's trust store. |
models |
every Claude model (claude-*) |
The hub's model allowlist for relayed runs, as globs. A project's maintainers can narrow it for their project from its Claude credential card (PUT /api/projects/{idx}/model-relay), but never widen it. A call for any other model is refused before it reaches the API. |
max_output_tokens |
32000 |
Clamps max_tokens on every relayed call, as the CI relay does. It also lowers an extended-thinking budget that would otherwise end up at or above max_tokens, which the API refuses. |
upstream_base_url |
https://api.anthropic.com |
Your own gateway in front of the API: an https origin, or loopback, with no path. |
hub_credential |
none | Where the hub's own anthropic.api_key may be used for a run that has no Claude credential granted: project:<path> (a feature through its parent) or executor:<id>. It is never a fallback: if nothing is listed, the hub's key is used nowhere. Has no effect until API keys are relayed (see below). |
Only tested credential kinds are relayed. A Claude OAuth token (claude
setup-token, CLAUDE_CODE_OAUTH_TOKEN) is relayed. This was tested on
2026-10-16 with real Claude Code through a hub and an enrolled device, and is
covered by TestLiveModelRelay, which relays real calls with a credential of a
given kind. API keys aren't relayed yet. A run with an API key gets its
credential delivered as before; its journal and the model_relay.not_relayed
audit row say so, and the project's Claude credential card shows not relayed
and why. The same applies to a run on a device whose agent predates protocol
v23, when the relay needs the harness's Node to trust the hub's CA (the
git_proxy listener under tls: auto): that agent can't pass the CA on, so the
hub records this in the journal and delivers the credential as before. While the
hub's CA is being rotated, an
agent older than protocol v25 gets only the active CA for Node, because it can't
take two. Its relayed run therefore trusts only that CA, and holds back an
activation like a run dispatched before the new CA was staged.
It fails closed. If the relay should serve a run but can't (it isn't
running, its listener is missing, or its session wasn't created), the dispatch
is refused with the reason, instead of handing the run its credential. The same
happens when the section doesn't load. A listener, models or
upstream_base_url the relay can't use is kept as written and marked, not reset
to a default that would allow more. The hub starts no relay, cloop hub doctor
fails config.repaired, and every run it would relay is refused until the
section loads. This also applies if the section is edited into a state that
doesn't load while the hub runs.
Reachability. On the git_proxy listener, the relay uses the same host and
port as the git proxy, so a firewall (a device's, a virtual executor's or a
project's egress rules) that lets a sandbox reach the git proxy also lets it
reach the relay, and nothing else needs allowing. With dashboard, the sandbox
must be allowed to reach the dashboard's address instead.
What Node trusts. NODE_EXTRA_CA_CERTS applies to the whole process: Node
trusts the hub's CA for every TLS connection the harness makes, not only for
the relay's URL, unlike git's per-URL setting. The file holds only the hub's CA,
and the CA's key never leaves the hub. listener: dashboard with a publicly
trusted certificate adds nothing at all.
enabled, listener and upstream_base_url are read at startup. models,
max_output_tokens and hub_credential are read on each dispatch while the
hub's configuration keeps the relay on. If you switch it off in the file, a
running relay keeps the section it started with until the restart that stops
it. A project's own config.yaml can't set any of these keys.
Automatic harness installation¶
A project that uses the claudecode provider needs the claude CLI wherever it
runs, and a copilot project needs the copilot CLI. A newly enrolled edge
device has neither, so by default the hub asks the device to install the one a
dispatch needs, using the vendor's official installer, the first time it is
needed:
The device reports what it did, and the dispatch continues. Turn it off to get the older behaviour, where the dispatch is refused with a message explaining how to install the CLI by hand:
Set it to false where you don't want the control plane to download and run a
vendor's shell script: a critical host, an air-gapped site, or anywhere changes
to installed software must be reviewed. It doesn't control what can be
installed; see below.
What the hub can ask for. The install frame names a harness, never a URL, a script, an argv or an interpreter. The table that maps a name to an installer is compiled into the agent binary on the device. Its entries are:
| Harness | Installer |
|---|---|
claude |
https://claude.ai/install.sh (Anthropic's documented install), then https://downloads.claude.ai/claude-code-releases/bootstrap.sh |
copilot |
https://gh.io/copilot-install (GitHub's documented install), accepted only from raw.githubusercontent.com/github/copilot-cli/ |
The second URL is where the first one redirects to, fetched directly. It is
tried only when the documented URL doesn't serve the script: the claude.ai
website answers many datacenter networks with a Cloudflare bot challenge (HTTP
403), but the download host, which the script downloads the binary from anyway,
doesn't. A cloud VM enrolled as an executor is typically such a machine. A
source refused by a safety rule (a redirect away from claude.ai, a downgrade
to plaintext, an oversized response) isn't retried elsewhere; the install stops
and says why.
GitHub's URL also redirects: gh.io answers 301 with the script in GitHub's own
github/copilot-cli repository on raw.githubusercontent.com. That host serves
every repository on GitHub, so allowing the domain alone would admit any user's
script. The entry therefore also pins the repository's path, and a redirect
anywhere else on the host is refused by a safety rule. There is no fallback URL
for it: the cloud VMs that the claude fallback exists for can reach gh.io, the
script and the release download it fetches. The script installs into
~/.local/bin from the CLI's release on github.com, checking the release's
checksums, and needs bash, curl or wget, tar and gzip. The CLI unpacks
a runtime of about 250 MB under ~/.cache/copilot the first time it starts.
So a compromised hub can ask a device to install Claude Code from Anthropic,
and nothing else. The agent downloads the script itself instead of piping
curl into a shell. That lets it reject a redirect that leaves the vendor's
domain, limit the response size, and capture the script's output, so a failure
is reported to the hub instead of scrolling past on a machine nobody is logged
in to. cloop itself isn't in the table: it already has
remote upgrade, which
verifies Sigstore provenance against a pinned identity, and a shell script would
be less secure.
Where it is installed. The official installers need no root and write to no
system path: the binary goes to ~/.local/bin/claude or ~/.local/bin/copilot.
That directory isn't on a systemd unit's PATH, so the agent adds it to its own
environment and to any payload environment that sets an explicit PATH. When
debugging, you can read the resolved path in the Fleet panel. A device that
installed the harness but still can't find it reports that instead of success.
When it is skipped. In container sandbox mode nothing is installed on the
device's host. The harness comes from the image, which is also how to fix a
device that lacks one. An agent older than protocol v12 doesn't know the install
frame, and gets the plain refusal instead of an error about its version. An
agent built before its table had copilot answers that it has no installer for
it, and the refusal includes that answer: upgrade the agent, or install the CLI
on the device by hand.
The setting is a ratchet: at runtime it can only be tightened. A control
plane manages many projects, each with its own config.yaml, and if any of them
could loosen it, a tenant could re-enable host execution by editing a file they
control. To loosen it, restart with a permissive config.
Resource ceilings¶
An upper limit on what any single workload can get, whichever executor runs it:
executors:
limits:
max_cpu: 4 # cores
max_memory: 8g # 512m, 2g, or a bare integer read as megabytes
max_disk: 20g # the workspace; see "Disk" below
max_pids: 2048 # processes/threads
Every field is optional. A missing field leaves that resource uncapped, so adding the section to an existing deployment changes nothing until you set a key.
This is the only resource setting in the file that is a limit and not a
default. executors.container.memory and the Kubernetes requests and limits say
what a workload gets when it asks for nothing, and a project's
.cloop/sandbox.yaml overrides them. That is right for a default
but no use as a policy, because the file is committed to the repository by
whoever can push to it. With overrides alone, a project could ask for
memory: 900g and get it, limited only by a built-in 1 TiB constant meant to
catch typos.
A ceiling also applies when nothing was requested. In a spec, a missing
resources.memory means no limit, not "the default", so a cap that only
lowered stated numbers could be avoided by deleting a line. That is also the
state of every project that has no sandbox spec.
The executor handles that case, in this order: the driver first resolves the
request against its own configured default (executors.container.memory and
similar keys), and then applies the ceiling to the result. So a project that
asks for nothing on a hub with container.memory: 4g and an 8 GB ceiling still
gets 4 GB, because a ceiling only ever lowers a limit. The ceiling sets a limit
itself only where nothing else did.
Like min_agent_build, it is applied as a ratchet: a hub reads many
tenants' config.yaml files, and otherwise one tenant could raise a cap that
applies to everyone else in the process. To loosen it, restart with the looser
config.
A malformed section doesn't stop the hub from starting, but it sets no ceilings
at all, and a banner says that no ceiling is being enforced. cloop config set
refuses a malformed value.
Disk (max_disk, and the Disk field of an executor's Limits) limits the
workspace: the tree the workload works in, counting what the run adds to its
.cloop/. It is applied like memory: a larger resources.disk is lowered to
it, and a project that sets none gets it. A Kubernetes executor enforces it by
eviction. A container executor, on the hub or on a device whose agent speaks
protocol v20 or later, measures the workspace while the workload runs and stops
the workload once it is over, which pauses the run with reason disk_limit.
This is enforcement by sampling, not a quota: a burst can overshoot by the write
rate times the sampling interval, which is why the
free-space floor still matters. Where nothing can enforce a
disk ceiling (the host-process driver, a device running payloads on its host, an
agent older than v20), every dispatch says so in the project's journal, and the
executor card shows disk: not supported. See
resources.disk and the workspace.
Upgrading from an older build turns these ceilings on. On a container
executor, older builds recorded no limit for a max_disk, or for a Disk value
in an executor's Limits. Current builds refuse runs there that are over it and
stop runs that go past it. A remote device running payloads in a container now
also gets its ceilings (CPU, memory and processes as well as disk) written into
the spec for a project that asked for nothing, where older builds limited only
the project's stated requests. Check the ceilings you have set before
upgrading.
Per-project resource ceilings¶
The fleet ceiling applies one limit to everything. To hold a single project lower (a noisy neighbour, an untrusted tenant), set a ceiling on it directly:
$ cloop hub limits set /srv/projects/noisy --memory 2g --reason "OOMing the box"
$ cloop hub limits list
$ cloop hub limits clear /srv/projects/noisy --reason "moved to its own node"
These are stored in the hub's control-plane database, not in any file the project can reach. A project states what it needs in its own repository, and the operator states what it may have somewhere the project can't edit.
The two ceilings combine resource by resource, and the lower one applies. The project can't raise either. A per-project ceiling above the fleet's is stored as written but has no effect: it is a second limit, never an exemption.
Ceilings are read on every dispatch, so a change applies to the next task that
starts, without a hub restart, and cloop hub limits works while a hub is
running. When a ceiling lowers a workload's limits, the project's event log
records the resource, both numbers and which ceiling applied. Otherwise a
sandbox that got 2 GB instead of 8 would look like a slow machine, with nothing
in the repository to explain it.
Security Model¶
What data leaves the machine¶
Whenever cloop uses a model (to run tasks, or for suggest, explain and so
on), it sends only the following to your configured provider:
| Data sent | When |
|---|---|
| Your project goal and task descriptions | Every prompt |
| Step output from previous tasks (for context) | When building task context |
Codebase snippets injected by --inject-context or cloop context |
When context injection is enabled |
| Git log / diff excerpts | Commands that require them (pr, commit-msg, trace, …) |
API keys, passwords and environment variables are never included in prompts. cloop itself sends no telemetry to Anthropic or any third party.
A hub with semantic knowledge search
turned on also sends the text of its knowledge chunks, and every knowledge
search query, to the embedding endpoint that knowledge.embeddings names. It
is off unless an administrator turns it on.
The hub can collect a diagnostic trail from browsers, but it never leaves the deployment: your own front ends send it to your own hub, it is stored in your own database, and credentials are scrubbed on the way in. See front-end telemetry, including how to turn it off.
API key storage¶
| Location | What is stored |
|---|---|
.cloop/config.yaml |
API keys (optional; environment variables are preferred) |
| Environment variables | Recommended: ANTHROPIC_API_KEY, OPENAI_API_KEY, GITHUB_TOKEN |
.cloop/state.db |
Goal, task list, step outputs; no API keys |
Config file permissions: cloop writes .cloop/config.yaml with mode 0600
(owner read/write only). On load, it warns if the file is readable by the group
or by others, so you can run chmod 600 .cloop/config.yaml.
Encrypted secrets (cloop secret) are stored in .cloop/secrets.enc with
AES-256-GCM and never included in a prompt.
The root key: CLOOP_SECRET_KEY¶
Every sealed credential, session refresh token and hub CA key, every audit
checkpoint seal and backup sidecar seal, the key escrow and .cloop/secrets.enc
are derived from one root secret. The hub and every command read it through one
resolver, from the first of these that is set, and refuse to run if two of them
hold different keys:
| Setting | Holds |
|---|---|
CLOOP_SECRET_KEY_FILE |
absolute path to a file holding the key: a regular file owned by the hub's user, mode 0400 or 0600, outside every .cloop/ directory (one trailing newline is ignored). A Kubernetes Secret volume (owned by root, mode 0440, readable through the Pod's fsGroup) is accepted too, and so is a file owned by root that an ACL lets only the hub's user read, which is how systemd 255 and later pass credentials to a unit with User= |
$CREDENTIALS_DIRECTORY/cloop-secret-key |
the systemd credential: LoadCredential=cloop-secret-key:<file>, or LoadCredentialEncrypted= with a file sealed by systemd-creds encrypt to the host's credential secret and, where there is one, its TPM |
CLOOP_SECRET_KEY |
the key itself, in the environment. Use this only as a last resort: it is readable in /proc/<pid>/environ and inherited by child processes |
CLOOP_SECRET_KEY_PREVIOUS_FILE, cloop-secret-key-previous, CLOOP_SECRET_KEY_PREVIOUS |
the key that a change of root is replacing, read the same way. It opens what it sealed and creates nothing new. Refused without a current key, or when it is the current key |
cloop hub bootstrap writes the key to cloop-secret-key next to .cloop/
(--key-file), encrypted with systemd-creds when the host supports it
(--systemd-creds auto|always|never). A running hub resolves the key once at
startup, prints the source and the key's fingerprint, and advertises the
fingerprint to its cluster peers. A changed file takes effect at the next
restart. The hub refuses to start if a live sealing key is derived from neither
configured root. See the root key in
the runbook. With secrets.kms, the secret
store's keys come from a KMS instead, and the root key seals everything else.
Sealing keys in a KMS: secrets¶
By default, every key-encryption key (KEK) is derived from CLOOP_SECRET_KEY
and a salt stored with it, so the database and the key file together open every
sealed credential, offline, for as long as anyone keeps a copy. With
secrets.kms, each KEK is random, and the database keeps only the ciphertext an
external key-management service (KMS) produced from it. Opening the store
requires a call to the KMS, which logs it and whose owner can refuse it.
secrets:
kms:
provider: kmsv2 # or transit
name: azure-prod # optional; derived from the endpoint or key
timeout: 5s # per call (100ms–1m)
startup_timeout: 30s # how long `cloop ui` waits for the KMS (0s–10m)
kmsv2:
endpoint: unix:///var/run/kmsplugin/socket.sock
# previous_root: passphrase # while moving from CLOOP_SECRET_KEY onto the KMS
# kms_previous: { … } # while moving off this KMS, or from one KMS to another
| Key | Meaning |
|---|---|
kms.provider |
kmsv2: a Kubernetes KMS v2 plugin over a unix socket, which covers Azure Key Vault, AWS KMS, Google Cloud KMS and Vault through the plugins their vendors publish for the API server. transit: HashiCorp Vault or OpenBao Transit over HTTPS |
kms.name |
what the key registry records as each key's root, and what a hub cluster member advertises. Defaults to kmsv2:<socket path> or transit:<host>/<mount>/<key>; set it if the endpoint may change |
kms.kmsv2.endpoint |
the plugin's socket: unix:///abs/path or /abs/path, nothing else, because a KMS reached over a network would send the keys it unwraps across that network. A socket in a directory all users share (/tmp, /dev/shm) is refused, because whoever bound it first would receive every key the hub wraps |
kms.transit.address |
https://vault.example.com:8200; plain http only to a loopback host. A redirect to another server is not followed |
kms.transit.mount, key, namespace |
the Transit mount (transit), key name, and Vault Enterprise / OpenBao namespace |
kms.transit.token_file |
a Vault token, re-read on every call (a Vault Agent sink renews it in place); a file other users can write is refused |
kms.transit.kubernetes.role, mount, token_file |
Vault's Kubernetes auth method instead: logs in as role with the service account token at token_file (default the Pod's), and again when the lease runs out |
kms.transit.ca_file |
a PEM bundle trusted beside the system roots |
previous_root |
passphrase while moving from CLOOP_SECRET_KEY onto secrets.kms: the passphrase still opens the keys it derives, and creates nothing new |
kms_previous |
a second KMS block: the KMS a change of root is moving off. Its name must differ from kms.name |
The keyring holds a current root and, during a change of root, a previous one:
| Configured | Current root | Previous root |
|---|---|---|
| nothing | CLOOP_SECRET_KEY |
none |
CLOOP_SECRET_KEY_PREVIOUS |
CLOOP_SECRET_KEY |
CLOOP_SECRET_KEY_PREVIOUS |
secrets.kms |
the KMS | none |
secrets.kms, previous_root: passphrase |
the KMS | CLOOP_SECRET_KEY |
secrets.kms_previous |
CLOOP_SECRET_KEY |
the KMS |
secrets.kms, secrets.kms_previous |
the new KMS | the old one |
There is at most one previous root: two are refused, and so is
CLOOP_SECRET_KEY_PREVIOUS together with a KMS root.
cloop hub key rotate --retire-previous completes any of these changes; see
keys in a KMS.
This section is hub-scope. The cloop
hub key commands and cloop db restore read it with the overlay of the hub
their --port names, and cloop ui reads it with its per-instance overlay
merged in. Every member of a hub cluster opens the same store, so
config.yaml is the usual place for it. Nothing falls back. cloop ui refuses
to start with a section it doesn't accept, and, after waiting
startup_timeout, with a KMS it can't reach. A key that no configured root
opens still stops the hub from starting. With a KMS as the current root, the
store doesn't need CLOOP_SECRET_KEY, but the key still seals the audit
checkpoints, backup sidecars and the escrow.
State file integrity (optional HMAC)¶
Set the CLOOP_STATE_HMAC_KEY environment variable to sign exported state with
HMAC-SHA256. The pkg/security package provides Sign() / Verify(), which
tools that export or import state can use to detect tampering.
Non-interactive access: API tokens¶
CI jobs, deploy scripts and edge devices can't hold a browser session. Give them
a scoped API token (cloop_pat_…):
# Read-only, one project, expires in 30 days
cloop hub token create ci-payments --role operator --project payments --expires-in 30d
cloop hub token list # id, roles, scope, status, last use
cloop hub token list --active # hide revoked and expired
cloop hub token revoke <id> # effective on the next request
cloop hub token approve <id> # a token minted while no role policy was in force
You can also use the Tokens section of the Secrets panel, or
POST /api/tokens. Either way, the value is shown once. cloop stores only a
salted hash, so a lost token can't be recovered; you create a new one instead.
A token doesn't bypass authorization. It carries roles, and every RBAC check applies to it as it does to a signed-in user:
| Field | Meaning |
|---|---|
--role |
roles the bearer acts with (repeatable). You can only grant roles you hold yourself. |
--project |
restrict to these projects (repeatable). Out-of-scope projects are reported as nonexistent, not forbidden. |
--expires-in |
90d, 12h, an RFC3339 timestamp, or 0 for no expiry. |
Send it as Authorization: Bearer cloop_pat_… (or ?token=… for
EventSource). Creating a token requires the token.admin permission, which only
admin holds, and the API also refuses to issue a token with more authority or
a wider scope than its creator. Creation, revocation and every failed
authentication are recorded in the audit trail (cloop events).
With single sign-on and no role policy, every signed-in identity holds every
role, so a token created from the dashboard in that state is only as
trustworthy as whoever was signed in. The hub records which policy applied when
each token was created. Once a role policy is in force, it refuses a token
created without one (or before the hub recorded this) until an administrator
approves it, with Approve in the tokens section (the approver must hold
every role the token carries) or cloop hub token
approve <id>. Such a token is listed suspended. A token that acts for an
owner, such as a glasses link, never is. The enforce prompt in Settings lists
every token it will suspend, and cloop hub doctor fails
api_tokens.unapproved while any remain.
Audit-trail retention¶
The hash-chained audit trail only grows. audit: limits it. It is off by
default, because deleting from a compliance record on a schedule nobody chose is
worse than the disk space it saves.
audit:
retention_days: 90 # 0 or absent: keep everything
export_dir: /srv/audit-archive # default: .cloop/audit-archive
prune_on_maintain: true # apply it during `cloop db maintain`
| Key | Default | Range | What it does |
|---|---|---|---|
retention_days |
0 (disabled) |
1–3650 |
How long rows stay in the database. Older rows are archived, then removed, never deleted outright. Out-of-range values fall back to 0, not to a default window. |
export_dir |
.cloop/audit-archive |
— | Where sealed prefixes are written. Put it on storage the hub doesn't otherwise control, so the archive can be compared with a database an attacker may have edited. |
prune_on_maintain |
false |
— | Lets cloop db maintain apply the window before it runs VACUUM, so the freed pages are returned to the filesystem. Otherwise only cloop hub audit prune applies it. |
The procedure, what an anchor is, and why a pruned chain still verifies are in the runbook.
Audit head checkpoints¶
A hash chain reveals edits anywhere, and deletions anywhere except at the newest
end: if the newest rows are deleted, the rest still verify. With
audit.checkpoints, the hub records how long each chain was, somewhere the
database can't reach. It is on by default, writing to stderr.
audit:
checkpoints:
interval: 5m # default 5m
file: /var/log/cloop/audit-checkpoints.jsonl # optional; outside .cloop/
stderr: true # default true
| Key | Default | Range | What it does |
|---|---|---|---|
interval |
5m |
1m–24h |
How often the cluster leader writes one record per chain: the control plane's every time, and a project's when its head has moved (and at least daily). Windows are numbered by the clock and claimed in a marker the control plane holds, so a leader change never writes a window twice. If a leader dies between claiming a window and writing it, the next leader writes it (late, if the window has ended by then) instead of skipping it. A value that doesn't parse, or is outside the range, runs at the default. |
file |
— | absolute path | Appended to and fsynced, one JSON record per line, mode 0600. Must be outside every .cloop/ directory, after resolving symlinks, and its directory must exist: cloop doesn't create it, so a missing directory causes an error. An unmounted mount point is still an existing, empty directory, so put the file in a subdirectory that exists only on the mounted volume (/mnt/audit/cloop/, not /mnt/audit/), or the records end up on the root disk underneath. A path that breaks these rules is dropped at load with a warning; cloop config set and cloop config validate refuse it. Put it on storage the database doesn't share. |
stderr |
true |
— | Prints each record as one JSON line on the hub's stderr, for a container platform's log pipeline to send off the machine. With false and no file, no checkpoints are written and cloop hub doctor warns. |
Each record holds the chain (control-plane or project), the database path,
the newest row's id and hash, the row count, the newest prune anchor and whether
the hub verified that anchor's archive (it reads each new anchor's archive
once), the time, and the hub member that wrote it. It is sealed with HMAC-SHA256
under a key derived from CLOOP_SECRET_KEY with its own label, and stamped with
the key's fingerprint. Without CLOOP_SECRET_KEY the records are unsigned. The
leading hub also writes every chain at a clean shutdown. Check the chains
against the records with cloop hub audit verify --checkpoints <file>; the
runbook lists
what it can detect. Like the rest of audit, the section is read from the hub's
effective configuration, including its overlay, at every window.
Audit forwarding (audit.forward)¶
Checkpoints record how long each chain is. audit.forward sends the rows
themselves off the hub as they are written. The cluster leader follows every
chain (the control plane's and each registered project's) and sends each row to
every sink. It advances a cursor per sink and chain in the control plane only
after the sink acknowledges. It is off unless sinks are listed.
audit:
forward:
hostname: hub-prod-1
max_lag: 15m
sinks:
- name: siem
type: syslog
address: siem.corp.example:6514
format: json
ca_file: /etc/cloop/siem-ca.pem
client_cert: /etc/cloop/hub-client.pem
client_key_secret: siem-client-key
- name: splunk
type: https
variant: splunk_hec
url: https://splunk.corp.example:8088/services/collector/event
token_secret: splunk-hec
- name: archive
type: file
path: /mnt/audit/cloop/audit.jsonl
| Key | Default | Range | What it does |
|---|---|---|---|
hostname |
the host name | printable ASCII, no spaces | Names this hub in what it sends: the syslog HOSTNAME, hub in every JSON record, the HEC host, the CEF dvchost. The host name follows the cluster leader, so a cluster should set it. |
max_lag |
15m |
1m–168h |
How old the oldest row a sink has not acknowledged may be before cloop hub doctor and cloop hub audit forward status report the sink. |
sinks |
— | at most 16 | The destinations. Each is described below. |
Every sink:
| Key | Default | What it does |
|---|---|---|
name |
required | Lower-case letters, digits, -, _, at most 32. Identifies the sink's cursors, its metric label and its rows in the audit trail; renaming a sink restarts it from start. |
type |
required | syslog, https or file. |
format |
json |
json (the cloop audit-log export --format jsonl record, with source, dir and hub) or cef (the export's CEF line). An https sink sends json. |
batch_size |
500 |
Rows read, sent and acknowledged as one, 1–5000. |
timeout |
30s |
How long one send may take, 1s–10m. |
start |
oldest |
Where the sink starts on a chain it has never sent: every row still in the database, or latest, the chain's head when the forwarder first sees it. |
ca_file |
— | A PEM bundle added to the system roots for this sink. |
client_cert, client_key_secret |
— | Mutual TLS: the certificate file, and the secret holding its private key. Both or neither. |
server_name |
from the address | The name the receiver's certificate is checked against. |
allow_plaintext_loopback |
false |
Acknowledges a plaintext sink (transport: tcp, an http:// URL) to a loopback address. Plaintext to any other address is refused whatever this says. |
syslog sinks send RFC 5424 messages in RFC 5425 octet-counted framing:
| Key | Default | What it does |
|---|---|---|
address |
required | host:port of the receiver; 6514 is the registered port for syslog over TLS. |
transport |
tls |
tls, or tcp to a loopback collector with allow_plaintext_loopback. |
facility |
local0 |
A syslog facility name: auth, audit, local0–local7, … |
app_name |
cloop |
The APP-NAME field, at most 48 characters. |
max_message_bytes |
8192 |
2048–1048576. A row larger than this is sent with its payload left out, and the record says so. |
A syslog batch counts as acknowledged once the receiver's TCP stack has acknowledged every byte on a connection it hasn't closed, because the protocol has no acknowledgement of its own.
https sinks send one POST per batch:
| Key | Default | What it does |
|---|---|---|
url |
required | https://…, or http:// to a loopback collector with allow_plaintext_loopback. Must not carry user:password@, or a query parameter whose name reads as a credential (token, key, secret, sig, auth, …). |
variant |
NDJSON | splunk_hec wraps each record as a HEC event and checks the reply's code. |
token_secret |
— | The secret holding the token presented in Authorization. Required for splunk_hec. |
auth_scheme |
bearer |
bearer, apikey (Elasticsearch), basic (the secret holds user:password) or splunk; splunk_hec always uses splunk. |
splunk_index, splunk_sourcetype, splunk_source |
token default, cloop:audit, cloop |
The HEC envelope's fields. |
max_batch_bytes |
1048576 |
65536–67108864 bytes per POST; a batch always carries at least one row. |
A batch is acknowledged by a 2xx (and, for HEC, code 0). A redirect is a
failed send, never followed, and no proxy from the environment is used.
file sinks append to the file and fsync it after each batch, mode 0600:
| Key | Default | What it does |
|---|---|---|
path |
required | An absolute path outside every .cloop/ directory, after resolving symlinks. Its directory must exist (the sink never creates it), so put the file in a subdirectory that exists only on the mounted volume: an unmounted mount point is an existing, empty directory. |
Secrets are referenced by name, never written here: a shared env secret in the
hub's secret store (cloop secret mint <name> --kind env --file …), as name,
or name#KEY for one key of a secret holding several. A sink that fails
validation (plaintext off the host, a file inside .cloop/, credentials in a
URL, a missing field) is dropped at load and recorded as switched off;
cloop hub doctor fails it, and cloop config set and cloop config validate
refuse it. Every member of a hub cluster must run the same section, because only
the leader forwards, using its own configuration. Keep it in config.yaml, or
in the same overlay on every member. Changes are recorded as
audit.forward.configured rows. The hub reads a sink's secrets when it starts
the sink, after a failed send, and at least hourly. The procedure, delivery
semantics and SIEM examples are in
the runbook.
Approval notifications (notifications)¶
Two kinds of decision wait for someone allowed to make them: a self-service
grant request, which needs a holder of secret.grant (and an administrator for
a request aimed at every project or executor), and an API token that a role
policy suspends until an administrator approves it. The dashboard's header
counts what is waiting for whoever is signed in, and its inbox lists it; see
the secrets guide.
notifications: sends the same news to where approvers already are. The
cluster leader posts once per cluster: when a request is filed, 12 hours before
it lapses, when it is approved, denied, withdrawn or lapses, and when a token
starts waiting. It is off unless webhooks are listed.
notifications:
dashboard_url: https://hub.example.com
webhooks:
- name: approvers
type: slack
url_env: CLOOP_NOTIFY_APPROVERS # the hub's environment holds the URL
- name: security-team
type: teams
url_env: CLOOP_NOTIFY_SECURITY
events: [request.filed, request.reminder, token.waiting]
- name: ticketing
type: json
url: https://hooks.corp.example/cloop/approvals
allow_addresses: [10.20.0.15] # the receiver is on the internal network
ca_file: /etc/cloop/corp-ca.pem
| Key | Default | What it does |
|---|---|---|
dashboard_url |
ui.external_url, then the origin of ui.oidc.redirect_url |
The address every message links to, opened on the approvals inbox (/?approvals=1). With none of the three, messages carry no link. |
webhooks |
— | At most 8 destinations, each described below. |
Every webhook:
| Key | Default | What it does |
|---|---|---|
name |
required | Lower-case letters, digits, -, _, at most 32. Identifies the webhook's delivery cursor and its metric label. A webhook added under a new name gets events from then on, not the backlog. |
type |
required | slack (an incoming webhook), teams (a Microsoft Teams Workflows trigger, "Post to a channel when a webhook request is received", sent an Adaptive Card), discord (one embed, with all mentions switched off), or json (one object: event, title, link, at, and request or token). |
url, url_env |
one of them | The webhook's address, https:// only. The URL is the credential for its channel, so it never crosses the network unencrypted, not even over loopback. url keeps it in this file (the Settings panel writes it there, and no API returns it). url_env names an environment variable of the hub process that holds it: CLOOP_NOTIFY_ followed by upper-case letters, digits or _. Use that form for a file kept in version control (with a Kubernetes Secret mapped into the environment, or a systemd EnvironmentFile). It is read at every send, on whichever member leads, so set it on every member. It isn't kept in the hub's secret store, because a maintainer can delete and re-create a shared secret, and the URL decides who hears about every request. |
events |
every event but requirement.regression |
Any of request.filed, request.reminder, request.approved, request.denied, request.withdrawn, request.lapsed, token.waiting, and requirement.regression: a requirement that passed its previous check and fails a later one, most often in the requirements gate's check of a task. requirement.regression is opt-in: it is sent only to a webhook whose events name it, so a channel set up for approvals doesn't hear about a project's tests unless it asks. |
allow_addresses |
— | IP addresses or CIDR prefixes a receiver on a loopback, private, link-local or carrier-grade-NAT address may be reached at. A prefix of every address, a cloud metadata service, or a prefix containing one is refused. |
ca_file |
— | A PEM bundle added to the system roots for this webhook. |
timeout |
10s |
How long one POST may take, 1s–1m. |
The sender checks the address it actually connects to, after DNS resolution, so
a public name that resolves or rebinds to a private address is refused at
connection time. It refuses a cloud metadata service whatever
allow_addresses says. It follows no redirects (a 3xx is a failed send), uses
no proxy from the environment, sends at most 32 KiB and reads at most 8 KiB of
the response. A message contains identities, the credential's name and kind, the
subject, the deadline and the link. It never contains credential material, the
requester's justification or an approver's note, and every string in it goes
through the credential-pattern scrubber first.
A failed delivery is retried with increasing backoff (30 seconds, doubling, at
most 30 minutes). A notice is dropped after 8 failed sends, on a refusal that
retrying can't change (a 4xx other than 408, 425 and 429, a redirect, a refused
address), or once it is a day old. A filing or reminder for a request that was
decided before it could be sent isn't sent. Deliveries are counted in
cloop_approval_notifications_total.
A webhook that fails validation is dropped at load and recorded as switched off;
cloop hub doctor reports it, and cloop config set and cloop config validate
refuse it. Settings → Notifications edits the section (administrators only),
writing it into the hub's overlay once the hub has one, and can send a test
message to a configured webhook. Each change is a
notifications.config.updated row. A webhook
removed there loses its place at once, so adding it back starts from new
events. One removed from a file is forgotten a day later. Only the leader sends,
using its own configuration, so a cluster's members should run the same
section: a webhook that only some members configure is sent to only while one of
them leads. Decisions are picked up from the time the leader last scanned, minus
two minutes, so members' clocks must agree to within that; run NTP. Delivery is
at least once: while two members both believe they lead, a notice can be posted
twice.
Semantic knowledge search (knowledge.embeddings)¶
The knowledge index ranks a project's chunks by their words, using SQLite's
full-text index and BM25, on the hub only. knowledge.embeddings adds ranking
by meaning. Each chunk gets a vector from an embedding endpoint when its source
syncs, and a search compares the query's vector with the vectors from the
project's bound sources and combines that ranking with the word ranking. It is
off unless enabled. When it is on, the hub sends the text of every chunk of
every synced source, and every search query, to the endpoint, so turn it on only
if that is acceptable. cloop hub doctor and Settings → Hub health always
show a knowledge.embeddings finding naming the endpoint.
knowledge:
embeddings:
enabled: true
provider: openai # any OpenAI-compatible API
endpoint: https://api.openai.com/v1 # posts to <endpoint>/embeddings
model: text-embedding-3-small
dimensions: 1536
secret_ref: openai-embeddings # a shared env secret holding the key
The same provider: openai reaches the other OpenAI-compatible endpoints:
| Endpoint | endpoint |
Notes |
|---|---|---|
| OpenAI | https://api.openai.com/v1 |
request_dimensions: true shortens a text-embedding-3 model's vectors to dimensions. |
| Azure OpenAI | https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=2024-10-21 |
The key goes in the api-key header, the default for an Azure OpenAI host. The v1 API (…/openai/v1) takes it too. |
| Ollama | http://localhost:11434/v1 |
No secret_ref. model: nomic-embed-text, dimensions: 768. |
| vLLM | http://<host>:8000/v1 |
secret_ref when the server runs with --api-key. |
provider: voyage uses Voyage AI's API (endpoint defaults to
https://api.voyageai.com/v1) and tells it whether it is embedding documents or
a query.
| Key | Default | What it does |
|---|---|---|
enabled |
false |
Turns semantic search on. |
provider |
required | openai or voyage. |
endpoint |
required for openai |
The API's base URL. An http endpoint that is not loopback sends chunk text, queries and the key unencrypted, and the doctor warns. A cloud metadata service is refused, here and at every connection. |
model |
required | The embedding model, as the endpoint names it. |
dimensions |
required | The length of the model's vectors, 1–8192. A vector of any other length is refused, stored nowhere and compared with nothing. |
request_dimensions |
false |
Ask the endpoint for dimensions-long vectors (OpenAI's dimensions, Voyage's output_dimension), for a model that can shorten its vectors. |
secret_ref |
— | The endpoint's key: a shared env secret (cloop secret mint <name> --kind env), name or name#KEY, opened by the hub as its own credential and never handed to an executor. Re-opened every 5 minutes and after the endpoint refuses it, so a rotated secret takes effect without a restart. |
auth |
api-key for an Azure OpenAI host, else bearer |
How the key is sent: Authorization: Bearer, or the api-key header. |
batch_size |
64 |
Chunks per request, 1–512. |
timeout |
30s |
How long one request may take, 1s–5m. A search waits at most 5 seconds for its query's vector, whatever this says. |
max_candidates |
50000 |
How many vectors one search compares its query with, 1,000–1,000,000. A project whose sources hold more is searched by meaning over that many and by words over all of them; cloop_knowledge_search_vectors_capped_total counts the searches it cut. |
The comparison is a brute-force scan of every current vector from the project's
bound sources, read from the hub's database, so its cost grows with the amount
of knowledge. On a 4-core host, comparing 50,000 vectors of 768 dimensions takes
about half a second, and ranking the same 50,000 chunks by words about 20 ms
(go test ./pkg/knowledge -run '^$' -bench Search50k). Lower max_candidates
to limit it; cloop_knowledge_search_vector_scan_seconds shows the cost.
Keys never go in the file. An api_key field, or an endpoint whose query string
has a parameter that looks like a credential, switches the section off at load,
as does a missing endpoint, model or dimensions. cloop hub doctor reports this
as a failure, and cloop config validate refuses it. The client follows no
redirects, uses the proxy named in the hub's environment (HTTPS_PROXY, never
for loopback), and reads a response only up to the size the requested vectors
could need.
What a sync does. After listing its source, each sync embeds, in batches, the chunks that have no current vector: new chunks, chunks of a document that changed, and every chunk when embeddings are first turned on or the model, provider or dimensions change. A model change makes every source due at once, so the backfill starts within a minute. A chunk whose document changed but whose own text didn't keeps its vector without a request. A pass stops after 10 minutes, and a source with more chunks waiting is synced again within a minute. Vectors are stored in the hub's database, one per chunk and model, as float32 values. They are deleted with their chunk, and the cluster leader purges other models' vectors hourly. Turning embeddings off keeps the vectors for when they are turned back on.
What a search does. The query's vector is requested while the words are
ranked. Then every current vector from the project's bound sources is compared
with it, through the same bindings and filters (spaces, query matches) as the
full-text search, never another project's. The two rankings are combined by
reciprocal rank (1/(60+rank) from each), and each hit says whether it was
found by words, meaning or both (found_by). The search response includes
retrieval: mode hybrid, the model and how many vectors it compared.
When the endpoint fails. Neither syncs nor searches fail because of the
endpoint. A sync whose endpoint is down, refuses the key, or returns vectors of
the wrong length ends ok with a warning in its status, and the chunks wait
for the next sync. A search falls back to ranking by words (mode lexical,
with a warning saying why), and for 30 seconds after a failure doesn't
contact the endpoint at all. The doctor's knowledge.embeddings.sync finding
lists the sources whose last sync couldn't embed. See
the metrics.
In a hub cluster, the leader's section decides what syncs embed, and each member's section decides what its own searches compare, so every member should run the same section. A member configured with a different model finds no vectors for it and ranks by words.
Disk retention¶
retention: controls the janitor the hub runs on a timer to keep .cloop from
growing without limit. Unlike audit: above, it is on by default: it reclaims
derived data and dead database pages, not a compliance record, and a hub that
fills its disk stops working.
A zero value for any key means "use the default", not "off", so a config file written before this section existed gets the default policy. You turn it off explicitly.
retention:
enabled: true # false disables the janitor entirely
interval_hours: 24 # how often a pass runs
keep_snapshots: 50 # .cloop/plan-history bound
vacuum_free_ratio: 0.30 # VACUUM once this fraction of state.db is free pages
vacuum_min_free_mb: 64 # ...but not for less than this
vacuum_max_inline_mb: 1024 # ...and not more than this without stopping the hub
archive_max_mb: 0 # 0: keep every audit seal
archive_max_age_days: 0 # 0: keep every audit seal
| Key | Default | Range | What it does |
|---|---|---|---|
enabled |
true |
— | Runs a pass every interval_hours. Set false and nothing reclaims .cloop automatically, including the row limits below. |
interval_hours |
24 |
1–336 |
How often a pass runs. The stamp at .cloop/retention-last-run makes the cadence survive a restart. |
keep_snapshots |
50 |
1–10000 |
How many plan snapshots are kept. Enforced at write time inside SaveSnapshot as well as by the pass, so a busy project can't get ahead of the interval. |
vacuum_free_ratio |
0.30 |
≥ 0.01 |
Free-page fraction at which state.db is rewritten. 1 or more never vacuums. A ratio, not a size: 2 GB of freelist is urgent in a 2.3 GB file and unremarkable in a 200 GB one. |
vacuum_min_free_mb |
64 |
1–1048576 |
An absolute minimum below the ratio, so a small database isn't rewritten daily to reclaim a few hundred kilobytes. |
vacuum_max_inline_mb |
1024 |
1–1048576 |
The most live data the hub will rewrite while it is running. The control-plane database holds the hub's own lease, so a VACUUM that takes longer than the lease TTL blocks the lease heartbeat, and the hub stands down, assuming another instance took over. Above this size the janitor skips the VACUUM and points to cloop hub retention --apply, which has no heartbeat to lose. Measured against live data, not file size: a 2.3 GB file holding 300 MB is quick to rewrite. |
archive_max_mb |
0 (disabled) |
1–1048576 |
Caps .cloop/audit-archive; the oldest seals are deleted first. Off by default, because a seal is the only remaining copy of the rows it holds. |
archive_max_age_days |
0 (disabled) |
1–3650 |
Deletes seals older than this. Off by default, for the same reason. |
Two safety rules can't be configured. The newest audit seal is never deleted, so a cap smaller than one seal can't empty the directory. And a VACUUM is refused while another hub holds the control-plane lease, or when the filesystem lacks room for the rebuild: SQLite needs free space before it returns any, so a nearly full disk is the worst time to try.
Every pass also removes the task verdicts the orchestrator writes next to each
live artifact (.cloop/artifacts/<id>_verdict.json, read by stale-task
recovery) once they are a week old and their task is no longer in progress. A
verdict whose task is still in progress is never removed, because recovery needs
it. cloop compact removes them on the same terms, after
--keep-artifacts-days (at least an hour).
Row tables¶
The keys above limit files and dead pages. These limit the tables that gain a
row per unit of work. Without limits these tables keep growing: on cloop's own
control plane, provider_calls once held 47.5 MB in 265 rows, 14% of the live
data in the database, because each row stores the full prompt and response of
one provider call.
retention:
provider_call_body_age_days: 30 # clear recorded prompts/responses after 30 days
provider_call_max_rows: 10000 # ...and delete the rows themselves past this many
step_max_rows: 20000
event_max_rows: 20000
cost_max_rows: 50000
telemetry_max_age_days: 30
knowledge_deleted_days: 30
| Key | Default | Range | What it does |
|---|---|---|---|
provider_call_body_age_days |
30 |
1–3650, or -1 |
Clears the stored prompt, system prompt and response of older calls, but keeps the row. The inspector's list and every cost report read only the metadata (timestamp, model, tokens, latency), which takes about 200 bytes; the bodies are the other 99%. A cleared call is marked as such, so the panel says "dropped by retention" instead of showing an empty prompt, and Replay asks for a new prompt instead of re-running an empty one. |
provider_call_max_rows |
10000 |
100–100000000, or -1 |
Maximum rows in provider_calls; the newest are kept. |
step_max_rows |
20000 |
100–100000000, or -1 |
Maximum rows in steps. This also limits a run's memory use, since state.Load reads every step. Skipped while a run is executing, because a live run rewrites its step history from memory, so rows deleted under it would come straight back. |
event_max_rows |
20000 |
100–100000000, or -1 |
Maximum rows in the unified event journal. |
cost_max_rows |
50000 |
100–100000000, or -1 |
Maximum rows in the cost ledger. The highest of these limits, because a cost row is tiny, cloop cost report aggregates over the whole table, and the ledger is mirrored to .cloop/costs.jsonl. It is there to keep the table bounded, not to prune it regularly. |
telemetry_max_age_days |
30 |
1–3650, or -1 |
Deletes browser telemetry older than this. There is no row key, because the write path already caps that table: a public ingest endpoint must not be able to fill a disk between two passes. |
knowledge_deleted_days |
30 |
1–3650, or -1 |
Purges the tombstone a knowledge document leaves when its source deletes it. The document's content (its chunks, full-text entries and matches) leaves the index at once. The tombstone keeps only its id and external id, so an earlier citation still resolves to "deleted" and a document that comes back keeps its id. Every pass also purges the documents and chunks of a deleted knowledge source, whatever this says: deleting a source removes them itself, and only an interrupted deletion leaves any behind. |
-1 keeps a table in full while the rest of the policy stays in force. These
keys need that opt-out because they are on by default; the archive keys don't.
A value outside the range falls back to the default, not to "off", so a typo
can't turn this policy off without anyone noticing.
Row pruning and VACUUM report separate numbers. Deleting a row frees a page
inside state.db; only a VACUUM returns pages to the filesystem. So the row
steps run first in a pass, and the same pass can then reclaim what they freed.
Every pass ends by truncating the database's write-ahead log, state.db-wal,
which all of the steps above wrote through and which SQLite never shrinks by
itself. This step has no key. It runs whenever the pass does, even alongside a
live run or other hub processes, because its checkpoint waits at most a quarter
of a second for another connection and reports "busy" instead of holding up
writers. The hub's leader also truncates its own database's log within a minute
of finding it over 64 MiB. See
The write-ahead log.
Preview or force a pass with cloop hub retention (a dry run unless you pass
--apply). cloop doctor and cloop hub doctor both report usage per
directory and an estimate of reclaimable pages. cloop hub doctor also reports
row counts per table and names any table the active policy leaves unbounded.
Free-space floor¶
orchestrator.min_free_disk_mb is how much free space a run needs before it
starts work. Without it, a full disk shows up as a failed write, and the first
write to fail is often the one recording a task's outcome. The run then stops
with state_not_persisted, sometimes losing the verdict sidecar at the same
time, and stale-task recovery falls back to the agent's own TASK_DONE.
| Key | Default | Range | What it does |
|---|---|---|---|
orchestrator.min_free_disk_mb |
1024 |
0, or 64–1048576 |
Before every task attempt and every evolve round, the run measures the volume holding the project's .cloop and the working tree's volume (the same volume, unless .cloop is a symlink to another disk). Below the floor nothing starts: the run pauses with reason disk_low, checks again every minute, and continues by itself once every volume is back above the floor plus a tenth. 0 turns the check off. |
What a disk_low pause looks like:
- The run stays up. Unlike every other pause, the process keeps running and
waits; Stop ends it. The dashboard shows Paused: volume / has 812.3 MB
free, below the 1.00 GB floor with a Stop button. The event history has a
session_pausedrow naming the volume, the free space, the floor and what was about to start, then asession_startedrow ("Run resumed: disk space recovered") when it continues. In parallel mode, the round in progress finishes and records its outcomes, but no new task starts. - A reserve backs it up. Each project keeps
.cloop/reserve, 16 MiB of preallocated blocks, created whenever the check passes. If writing a task's verdict or outcome still fails for lack of space (because its own build filled the disk between the check and the write), the run deletes the reserve and writes again, then pauses withdisk_lowbefore starting anything else. It stops withstate_not_persistedonly if that retry fails too. Snapshots,cloop compact, the janitor and the disk-usage reports all leave the reserve out. - A run on another machine measures its own disk. A container or device run applies the project's own setting to the volume it writes to.
- A disk limit doesn't replace it. A container executor enforces a
workload's
resources.diskor disk ceiling by sampling its workspace, and a burst can overshoot by the write rate times the sampling interval before the next sample stops it. The floor stops that burst from filling the volume everything else writes to.
The hub applies the same floor to its own state volume. cloop hub doctor
reports storage.free_space (a warning below twice the floor, a failure below
it), admins see a banner across the dashboard while free space is below the
floor, and every hub process exports cloop_hub_disk_free_bytes{volume}.
Admins can edit the floor in Settings → Disk & Retention, which writes it
where the hub reads it: its per-instance overlay if it has one, otherwise
config.yaml. A run reads its project's config.yaml, so for runs in the hub's
own directory the hub passes the Settings value down as
CLOOP_MIN_FREE_DISK_MB. cloop config set orchestrator.min_free_disk_mb <MB>
and cloop config validate apply the same bounds. A value outside the range in
a file falls back to the default, not to "off".
Interactive access: single sign-on and sessions¶
ui.oidc.* configures OpenID Connect for the dashboard. The full setup is in
the security model.
This section covers the keys that control how long a session lasts and how
quickly it can be revoked.
You can edit all of ui.oidc in three ways: in this file, with
cloop config set ui.oidc.<key> <value>, and in Settings → Single sign-on
in the dashboard. The panel requires user.manage (not config.write),
refuses a block that would stop the hub from starting, and its changes take
effect when the hub restarts; see
editing it from the dashboard.
role_mappings is a list of records, so cloop config set can't edit it; use
the file or the panel's table.
default_role and role_mappings together decide whether a role policy is in
force. With neither set, single sign-on runs with RBAC off: every identity the
issuer authenticates holds every permission except executor administration
(admin_emails). The hub keeps this behaviour for deployments that enabled SSO
before RBAC existed, and warns about it: a warning on stderr at every start, a
failing rbac.enforced in cloop hub doctor, and a notice in Settings with an
Enforce deny-by-default button. With require_rbac: true, the hub refuses
to start in this state; see
when RBAC is in force.
If two hubs share a working directory, put ui.oidc in that hub's
.cloop/config.ui-<port>.yaml instead:
redirect_url names one origin, and the other hub can't serve the callback.
For Microsoft Entra ID there is a complete Terraform module at
deploy/terraform/azure-entra-id/.
It creates the application registration, declares cloop's four roles as Entra
app roles, and prints the ui.oidc block to paste in here. The block gets
right the provider-specific details you otherwise discover only when a sign-in
fails: the
issuer must end in /v2.0 and name one tenant, the hub's bare origin must be a
registered redirect URI or sign-out leads nowhere, and offline_access must be
in scopes, or Entra issues no refresh token and both revocation settings below
stop working without an error.
ui:
oidc:
session_ttl_hours: 24 # absolute ceiling, set at sign-in
idle_timeout_hours: 8 # ends an unused session sooner
refresh_interval_minutes: 15 # how often the IdP is re-asked
max_claim_age_minutes: 5 # claim freshness required for privileged actions
sudo_max_age_minutes: 120 # privileged actions need a sign-in this recent; -1 off
clock_skew_seconds: 300 # leeway on an ID token's exp/iat
require_idp: false # refuse to start if the issuer will not resolve
require_rbac: false # refuse to start with SSO on and no role policy
| Key | Default | Range | What it bounds |
|---|---|---|---|
session_ttl_hours |
24 |
1–720 |
The absolute limit. Activity never extends it; when it runs out, the user signs in again. |
idle_timeout_hours |
8 |
1–720, and never above session_ttl_hours |
How long a session can go unused. This limits an unattended browser, and is usually the one to shorten first: a shorter idle timeout means signing in again after a long meeting, while a shorter absolute limit interrupts people in the middle of their work. |
refresh_interval_minutes |
15 |
1–1440, or -1 to disable |
The longest delay between the identity provider disabling a user and their cloop session ending. Requires CLOOP_SECRET_KEY. |
max_claim_age_minutes |
5 |
1–60, or -1 to disable |
How old a session's group and role claims can be when it performs an action above operator. Past this age, the provider is asked again before the action, and the action is refused if the provider can't answer. With CLOOP_SECRET_KEY the hub asks using the session's refresh token; without it, the dashboard renews the claims from the user's browser instead (see below). |
sudo_max_age_minutes |
120 |
1–1440, or -1 to turn off |
Sudo mode: how recently the person behind a session must have signed in interactively to perform a privileged action. The privileged actions are: creating or deleting a secret or a grant, creating or approving an API token, creating an enrollment token, editing this block, retiring the static token, rotating the hub CA, upgrading or deregistering an executor, attaching to a sandbox, granting maintainer membership, ending someone else's session, offboarding, creating a SCIM credential or a glasses link, and setting a provider's API key or base URL, the dictation key or the CI relay's settings. Measured from the id_token's auth_time or, where the provider doesn't report it (Entra ID doesn't, unless that optional claim is configured), from a sign-in the hub itself started with prompt=login. Silent renewal never counts. If the sign-in is older, the action is refused with 401 REAUTH_REQUIRED, and the dashboard asks the person to confirm their identity at the provider (prompt=login&max_age=0). A terminal session counts from the sign-in behind its cloop login approval (the approval itself, if it asked you to sign in, which it does whenever the approving browser's sign-in isn't recent), and is told to run cloop login again. API tokens and the static token aren't sessions and are never asked. -1 turns it off, and cloop hub doctor and Hub health then warn (oidc.sudo). Editable in Settings → Single sign-on, and honoured in a per-hub overlay like the rest of this block. See Recent sign-in for privileged actions. |
clock_skew_seconds |
300 |
0–600, or -1 for none |
Leeway applied to an ID token's exp and iat, for hosts whose clocks disagree with the provider's. |
require_idp |
false |
— | Makes an unresolvable issuer fatal at startup instead of a warning. Also available as cloop ui --require-idp. |
require_rbac |
false |
— | Makes single sign-on without a role policy (no role_mappings and no default_role) fatal at startup instead of a warning. The Settings panel refuses to save a block that would fail this check, and cloop hub doctor says the start will fail. |
Out-of-range values are clamped, not rejected, and an idle_timeout_hours
larger than session_ttl_hours is lowered to it, because an idle timeout that
can never fire would remove the protection without any sign.
clock_skew_seconds is the exception: above the limit it is rejected, not
clamped. It exists for deployments that can't fix the underlying problem, such
as an on-premises provider on a host whose NTP is blocked by the same firewall
that keeps the provider on-premises. Raising it means accepting tokens that
expired that long ago, so raise it only as far as the drift you have measured.
If a value of an hour were clamped to ten minutes without a warning, the
operator would end up debugging the wrong problem.
Failing fast on a misconfigured provider¶
The hub resolves its issuer at startup instead of at the first sign-in: it runs discovery and fetches the JWKS, with ten seconds for both.
- It logs the issuer URL, the HTTP status and a one-line fix if that fails. Otherwise the failure would show up hours later as an error page from the provider, in the provider's terms, about a value the provider never saw.
- It reports
/readyzas unready (503with"check": "identity") for as long as the issuer has never resolved. Once it has resolved, the check keeps passing: existing sessions keep working through a later provider outage, and removing the hub from its Service during one would turn a sign-in outage into a complete outage. - With
require_idp(or--require-idp) it refuses to start, so a Kubernetes rollout fails and rolls back instead of replacing a working hub with one nobody can sign in to.
A failed startup check never disables normal sign-in, so if a provider is down
for a minute, the next sign-in works again without a restart. cloop hub doctor
makes the same two requests and prints the resolved authorization, token and
JWKS endpoints. That is usually the quickest way to check the hub is using the
realm you expect: a wrong realm looks fine in the config file but is obvious in
its authorization endpoint.
A role mapping the provider can't satisfy gets its own warning. ui.oidc
validates role names and claim kinds at startup, but it can't see whether the
provider actually sends the claim a binding reads. So on a deployment whose
groups scope was never granted, a group binding has no effect, and every
user falls back to default_role without an error. After the first identity the
hub authenticates, it logs one warning per such binding, naming the claims that
were present. See
Configuring role mappings.
Sessions are stored in the hub's own state.db and survive a restart or a
rolling upgrade. The refresh token used to revalidate a session is sealed with
AES-256-GCM under CLOOP_SECRET_KEY, the same variable
cloop secret uses. Without that key, cloop keeps no
refresh tokens at all (instead of storing a live credential in plaintext), so
refresh_interval_minutes has no effect and revocation at the IdP doesn't
reach cloop. cloop ui says so at startup, and the Active sessions panel
shows a banner.
max_claim_age_minutes has a second way to renew claims, so it keeps working
without a key. With no refresh token, the hub can't ask the provider itself and
refuses actions above operator once the claims are too old. The dashboard then
asks from the user's browser: a hidden frame makes a prompt=none round trip
through GET /auth/renew shortly before the claims expire, and again, followed
by one retry, if a call is refused because of their age. When this works,
nothing changes on screen. When the provider needs to see the user (they are
signed out there, or their browser blocks the provider's cookies in frames, as
Safari and Firefox do), a banner offers a sign-in that returns to the same tab
and view. See
Silent renewal from the browser.
Where browsers can't complete the silent round trip, privileged actions need a
visible sign-in once per max_claim_age_minutes period. The lasting fix is
CLOOP_SECRET_KEY with offline_access in scopes. The alternative is
max_claim_age_minutes: -1, which explicitly accepts acting on the claims from
sign-in time.
A session that has ended (its idle or absolute timeout ran out, or it was
revoked) no longer shows the access-token prompt either. Every 401 an SSO hub
sends says where to sign in again, and the dashboard goes there through the
provider and returns to the same view. If the user is still signed in at the
provider, that takes two redirects and no prompt.
Claim freshness for privileged actions¶
refresh_interval_minutes is a background interval: it limits how long a
disabled account keeps a working session. It doesn't limit how old the claims
inside a live session are, and on most providers those are very different
numbers. RFC 6749 doesn't require an id_token on a refresh, and Okta, Auth0
and Keycloak all leave it out in common configurations, so the grant is renewed
every interval while the groups and roles stay as they were at sign-in. cloop
asks the userinfo endpoint in that case, but on a deployment with neither, a
session keeps its sign-in-time permissions for its whole life.
max_claim_age_minutes limits the age of the claims, and only where it
matters. Before using a permission above the operator tier (secret.grant,
secret.revoke, user.manage, token.admin, session.admin,
executor.manage, config.write, project.write, project.share,
audit.read, sandbox.attach, knowledge.admin), cloop checks when the
provider last confirmed that session's claims. If that was longer ago than this
limit, cloop asks the provider before acting, decides based on its current
answer, and refuses the action if the provider can't be reached.
Three things keep this cheap:
- Reads never wait for it. Everything a viewer or operator can do, including running and stopping plans, stays on the background interval, so an IdP outage makes the hub slow to revoke access, never slow to respond.
- Concurrent checks share one request. A dashboard panel that sends six admin requests at once causes one call to the provider, and all six get the same answer. Sending more requests can't get ahead of a demotion.
- Fresh claims cost nothing. Only the first privileged call in each window contacts the provider; the rest need one indexed read.
The provider's own expires_in can shorten the limit but never lengthen it. An
IdP that issues five-minute access tokens is saying how long it vouches for the
claims, and cloop uses whichever deadline comes first.
The Active sessions panel and cloop hub session list show each session's
claim age next to its last grant check, so you can see the gap between them. A
session whose claims the provider has refused to confirm (a 401 or
invalid_token from userinfo) keeps working for reads and is refused for
everything above operator until a later check succeeds. Both outcomes are
written to the audit trail, as session.claims_rejected and
session.claims_stale.
Users with the session.admin permission get an Active sessions table in the
Secrets tab (subject, IP, device, sign-in time, idle time and a Terminate
button), backed by GET /api/sessions and DELETE /api/sessions/{id}. Any
signed-in user can end their other sessions from the header
(POST /api/session/logout-all). Signing out also redirects to the provider's
end_session_endpoint when it has one, so the browser's session at the IdP
ends too. How revocation works, including when the IdP is unreachable, is
described in
Session lifecycle and revocation.
Terminal sign-in (cloop login)¶
A hub with single sign-on can also sign in terminals. cloop login <hub> prints
a code, a signed-in browser approves it at /device, and the terminal gets a
CLI session: a row of kind cli in the same session store, authenticated with
Authorization: Bearer cloop_cli_…. For a walkthrough, see
Working from a terminal.
ui:
oidc:
device_login: true # on wherever SSO is; false turns it off
cli_session_ttl_hours: 24 # default and ceiling: session_ttl_hours
cli_idle_timeout_hours: 8 # default and ceiling: idle_timeout_hours
device_approval_max_age_minutes: 120 # a sign-in longer ago re-authenticates to approve
| Key | Default | Range | What it bounds |
|---|---|---|---|
device_login |
true |
— | Whether POST /api/device/code and /api/device/token answer, and /device is served. When off, cloop login reports that terminal sign-in is turned off. |
cli_session_ttl_hours |
session_ttl_hours |
1–session_ttl_hours |
A terminal session's absolute lifetime, from its approval. Never longer than a browser session's, because a credential stored in a file should expire first. |
cli_idle_timeout_hours |
idle_timeout_hours |
1–idle_timeout_hours, and never above cli_session_ttl_hours |
How long a terminal session can go unused. The janitor applies each kind of session's own idle timeout. |
device_approval_max_age_minutes |
120 |
1–1440, or -1 never to ask |
How long ago the approving person may have signed in. The approval's round trip asks the provider for this max_age; a browser session older than this is sent with prompt=login&max_age=0 instead. Where the id_token includes auth_time, the hub checks it: a sign-in older than this behind a fresh dashboard session (a silent re-login) goes round again with prompt=login, and one that ignored the prompt is refused, so a provider that ignored either parameter is caught. A provider that reports no auth_time (Entra ID, unless that optional claim is configured) is trusted to have honoured them. |
Everything else works as for browser sessions: refresh_interval_minutes,
max_claim_age_minutes and sudo_max_age_minutes apply unchanged, because the
approval is itself an authorization-code sign-in whose id_token and refresh
grant become the CLI session's own (never a copy of the browser's). Without
CLOOP_SECRET_KEY no refresh grant is kept, so a terminal session's claims stay
as they were at approval, and actions above operator need a new cloop login
once the claims are older than max_claim_age_minutes. The terminal is told
this when it signs in. Sudo mode counts a terminal session from the sign-in
behind its approval. Approving from a browser whose own sign-in is older than
sudo_max_age_minutes makes the approval a prompt=login sign-in, which the
terminal then counts from; a silent approval leaves the terminal no more recent
than the browser. A privileged action (creating a token, approving a request,
opening a sandbox terminal) from a terminal outside the window is refused, and
the terminal says to run cloop login.
Two endpoints answer before authentication, because a terminal asking for a code
has no credential yet. They accept JSON only, like every state-changing request,
and are rate-limited instead of authenticated: twelve codes per client address
per ten minutes, two thousand pending across the hub, each code expiring after
ten minutes and useless until a signed-in person approves it, and polling held
to the interval by slow_down. The browser's three routes
(/api/device/lookup, approve, deny) accept a browser session cookie and
nothing else: not a terminal's session, an API token or the static token. See
the HTTP API for each.
Projects created by name (ui.projects_root)¶
cloop remote init creates a hub project from a checkout on a developer's
machine, which can't know where the hub keeps projects. It asks for a project
by name, and the hub creates it under its projects root:
If unset, it is projects/ in the home directory of the user the hub runs as:
/var/lib/cloop/projects in the packaged image, whose HOME is its state
volume. The Helm chart mounts its state volume's projects/ there, because
everything else in its container is read-only and a project can't be inside the
state directory itself.
A name is one path segment of letters, digits, dots, hyphens and underscores,
starting with a letter or digit. A relative projects_root is refused when it
is used, with an error naming the setting.
Projects are also confined to the root. Every sandbox a project runs in gets the project's directory mounted read-write, so:
- Anyone without
executor.manage(maintainers using the dashboard's New Project dialog,cloop remote init --dir) can create a project only strictly inside the root, and not in a hidden directory. Outside it, the answer is403 FORBIDDEN, namingexecutor.manageand the root. A project created by name is always inside the root. - Nobody can create a project at the root itself or at a directory that
contains it; at, inside or above the hub's own directory or another hub's (one
holding
.cloop/hub.env, or at or above a runningcloop ui's); overlapping another project; or anywhere the older rules refuse (system directories,/, top-level directories, the hub user's home). The exception is a root inside the hub's own directory, as in the packaged image: projects inside such a root are allowed. Symbolic links are resolved before any of this is checked. - A directory that already holds a project isn't initialised again, because
cloop initwould overwrite the state it finds. To anyone other than an administrator or someone who can see that project, this refusal is the same409 CONFLICT, with the same wording, as every other refusal that depends on what is on the host, so creating projects can't be used to discover what exists where. ?delete_root=trueon a project's removal deletes its directory only if this hub created that directory itself, inside the root. For an adopted directory (a checkout that existed first, a path an operator registered), the request is refused with409, and the directory and the project stay.
Projects registered before these rules, or named with cloop ui --projects, are
listed but flagged on the Projects tab, refused at every run, and failed by cloop
hub doctor (projects.confinement) until an administrator moves or removes
them. Nothing is moved or deleted automatically. See
which directory a project may be.
SCIM provisioning (ui.oidc.scim)¶
The identity provider provisions the hub's users and groups at /scim/v2, and
deactivating a person there suspends them here without an administrator: their
sessions, API tokens, glasses links, leases and running tasks are ended, and a
deny binding is written. The provider authenticates with a credential created
by cloop hub scim credential create or under
Settings → Provisioning (SCIM); it isn't stored in this file. /scim/v2 is
served only with ui.oidc.enabled. For a walkthrough, including Entra and
Okta, see SCIM provisioning.
ui:
oidc:
scim:
match_claim: email # email | preferred_username | oid | sub
offboard_after_days: 0 # 0: only the provider's DELETE offboards
rate_limit_per_minute: 600 # per credential
| Key | Default | Range | What it decides |
|---|---|---|---|
match_claim |
email |
email, preferred_username, oid, sub |
How a provisioned user is matched to a signed-in identity: the user's primary email against the email claim, userName against preferred_username, or externalId against oid (Entra, with objectId mapped to externalId) or sub (Okta). If two identities carry the same value, cloop doesn't guess between them. Any other setting value stops the hub from starting. |
offboard_after_days |
0 |
0–3650 |
Runs the full offboarding (honouring a legal hold) for a user the provider has kept deactivated this long. 0 never does. Entra sends no DELETE for an account that was only unassigned, so this setting is what completes those. |
rate_limit_per_minute |
600 |
10–60000 |
Each credential's request budget, refilled continuously, with bursts of a sixth. Past it, 429 with Retry-After. |
Out-of-range values are clamped. All three can be set with cloop config set
ui.oidc.scim.<key>, and take effect when the hub restarts.
Provisioned groups are a claim kind, scim_group, which role_mappings and
runtime bindings can name by the group's display name or externalId. The hub
reads membership from its directory at every authorization decision, so it
needs no group claim in the token. The provider's accounts add two more kinds a
binding can name: username (preferred_username) and oid.
Web UI (cloop ui)¶
Where the dashboard listens depends on whether it has sign-in. A hub with no
sign-in (neither ui.oidc nor a static --token/CLOOP_UI_TOKEN) answers
every request from anyone who can reach it: it lists the projects, queues tasks
and starts agent runs on this host. So it listens on 127.0.0.1 only, and says
so at startup:
cloop dashboard running at http://127.0.0.1:8080, listening on 127.0.0.1:8080
No sign-in is configured, so the dashboard listens on 127.0.0.1:8080 only. To reach it from another machine, configure SSO (ui.oidc) or a token (CLOOP_UI_TOKEN) — the hub then listens on every interface — or tunnel: ssh -L 8080:127.0.0.1:8080 <this-host>.
A hub with sign-in listens on every interface. ui.listen (or --listen, which
overrides it) sets a specific address instead. It doesn't include the port,
which is still set by --port:
ui.listen unset |
loopback: 127.0.0.1, ::1, localhost |
beyond loopback: 0.0.0.0, ::, an interface's address, a host name |
|
|---|---|---|---|
| no sign-in | 127.0.0.1 |
as named | refused, or with ui.allow_unauthenticated_network: true, as named, with a warning at every start |
| static token | every interface | as named | as named |
ui.oidc |
every interface | as named | as named |
ui:
# The address, never the port. --listen overrides it.
listen: 127.0.0.1
# Only for a hub without sign-in that must listen beyond loopback anyway:
# a lab network, a container published on the host's loopback. It never
# widens the default, and the hub warns at every start while it applies.
allow_unauthenticated_network: false
- A scoped API token is not sign-in. API tokens restrict the callers that
present one, but a hub without
ui.oidcor a static token still serves requests that present none. Such a hub counts as having no sign-in, so removing the static token from a hub without SSO (migrating off it) takes the hub off the network instead of leaving it open there. - The error says what to do. A hub without sign-in that is asked to listen beyond loopback exits before it touches the control plane:
Error: refusing to listen on *:8080 (--listen): this hub has no sign-in — ui.oidc is off and no --token or CLOOP_UI_TOKEN is set — so anyone who can reach that address could list its projects, queue tasks and start agent runs on this host.
Remote executor agents need to reach the hub, and a hub that serves them over the network needs SSO (ui.oidc) or a token (CLOOP_UI_TOKEN).
To keep it on this machine, drop --listen: a hub without sign-in listens on 127.0.0.1, which an SSH tunnel (ssh -L 8080:127.0.0.1:8080 <this-host>) or an authenticating reverse proxy can reach.
To serve it without sign-in anyway, set ui.allow_unauthenticated_network: true in .cloop/config.yaml.
- Remote executor agents connect to the hub, so a hub that enrolls devices over the network needs SSO or a token. A loopback-only hub serves agents on its own machine, or through a tunnel.
- Loopback is judged from the address, never by resolving it. IP literals
count by their value, and
localhostis bound as127.0.0.1. Every other host name (hub.localhost, or this machine's own name mapped to127.0.1.1) counts as beyond loopback, whatever it resolves to now. - Behind a TLS proxy on the same host, set
listen: 127.0.0.1on a hub with sign-in too: the proxy can reach it there, and nothing else can reach the plaintext port. While a hub serves plaintext beyond loopback and its public URL (ui.external_url, orui.oidc.redirect_urlif that is unset) is https, startup prints a note andcloop hub doctorwarns (ui.exposure). - Per process. Where two hubs share a directory, put
ui.listenin each hub's own overlay,config.ui-<port>.yaml(see below). - Containers set the address. A container has its own loopback, so the
image, the Helm chart and the compose stack start
cloop ui --listen 0.0.0.0. A container hub without sign-in refuses that and exits. See Deploying the cloop hub. - Hub-cluster members reach each other at their advertise URLs, which
default to the address the member is bound to, or to
http(s)://127.0.0.1:<port>for a member on every interface.
cloop hub doctor --port N reports ui.exposure. It reads where the process on
port N actually listens from the kernel's socket table, and requests
/api/projects from it without credentials. It reports fail for a hub
without sign-in that can be reached beyond loopback, whatever its build, so an
older binary that binds every interface regardless is caught too. It reports
warn for plaintext behind an https URL, as described above. If nothing is
listening on the port, it judges what cloop ui --port N would do with this
configuration (a static token counts when CLOOP_UI_TOKEN is exported in the
doctor's shell).
--token/CLOOP_UI_TOKENis deprecated. It works until you retire it, but it bypasses RBAC entirely, sees every project on the hub, never expires, and can't be revoked for one caller without breaking all the others. Create a scoped API token per caller (see above). Once single sign-on works, retire the static token withcloop hub token static retire --reason "..."or Settings → Static admin token → Retire, and remove the flag and the environment variable at the next rollout. While it is still set,cloop uiprints a warning at startup and the Tokens panel shows a banner, and while it is accepted alongside an enforced role policy,cloop hub doctorwarnsui.static_token. See Migrating off the static token and Retiring the static token.
When a --token is set:
- Every
/api/*request must presentAuthorization: Bearer <token>or?token=<token>. - A retired token is refused by every hub member, without a restart, with a
401saying when and by whom it was retired and what to present instead. This applies in the header and as?token=, and doesn't count toward the rate-limit lockout below. The token stays configured: a token-only hub whose token is retired stays closed and accepts only API tokens. Retirements are stored in the control plane'sretired_static_tokenstable, keyed by the token's fingerprint. A member reads them before it starts serving, when a retirement posts its notice on the hub bus, and every 30 seconds. To issue a new token, deploy a new value. - Failed authentication attempts are rate-limited: after 5 consecutive failures from the same IP, the endpoint returns HTTP 429 and blocks that IP for 60 seconds.
- The dashboard's assets under
/assets/are served before authentication, like the page that references them. They are static files compiled into the binary, the same on every hub. - All responses include these security headers:
Content-Security-Policy: only the hub's own script files run; see belowX-Content-Type-Options: nosniffX-Frame-Options: DENY. With single sign-on configured, it isSAMEORIGINon/auth/renewand the OIDC callback only, which the dashboard loads in a frame to renew a session's claims (frame-ancestors 'self'in the CSP, whoseframe-srcthen also names the identity provider)Referrer-Policy: no-referrer- No CORS headers are sent to anyone.
Content-Security-Policy and violation reports¶
Every response the hub sends (the dashboard, the display-glasses page, the sign-in documents, assets and API responses alike) carries one policy:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; connect-src 'self'; frame-src 'self' [identity provider];
frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none';
report-uri /api/csp-report
frame-src names the identity provider when single sign-on is configured, and
frame-ancestors is 'self' on /auth/renew and the OIDC callback, the two
documents that silent renewal loads in a frame. Nothing else varies, and nothing
is configurable.
Only the hub's own script files run: no inline scripts, inline event handlers,
eval or javascript: URLs. The dashboard routes every control through one
delegated dispatcher. A control names an action in a data-action attribute,
which pkg/ui/assets/js/00-core.js looks up in a registry, and a pointer
passing over an element or a drag crossing it runs only what was registered for
that specific event. So a string written by a workload or a peer that reaches
the page through a missed escape can't run, and neither can an event handler
someone adds back into the markup: TestNoInlineEventHandlers fails the build
first. The theme bootstrap and the display-glasses page's script are separate
assets, and the two sign-in documents carry their values in the markup and load
GET /auth/sign-in.js. Inline styles are still allowed; see the
threat model.
report-uri names the hub's own POST /api/csp-report. A browser sends it a
report for each violation, and the hub:
- counts it in
cloop_csp_violations_total, by effective directive and by which of its documents it came from; - logs it at warn level as
csp_violation, with credentials scrubbed from its URLs; - records it as a telemetry event of kind
cspwhereui.telemetrycollects that document's front end (telemetry).
The log line and the event name the signed-in user whose browser sent the report (the report carries the session cookie, which a browser sends with a same-origin report), or nobody. Reading that session doesn't count as activity by the user.
The endpoint takes no credential and is served before authentication. The
glasses page's credential is a header its script adds, and the sign-in
documents are for someone not yet signed in, so a browser has no credential to
send with a report from either. The endpoint is limited accordingly: POST
only, application/csp-report or application/reports+json and nothing a form
can send, a 64 KiB body, the first 32 reports of a batch, and the per-IP rate
limit that applies to every route. Log lines and telemetry rows come from two
hub-wide budgets. One allows 30 at once and 60 a minute, for a signed-in
browser's reports and for every report on a hub that requires no credential.
The other allows 10 at once and one a minute for everything else (the glasses
page's, a static-token dashboard's, a sign-in error page's, anyone's). So
anonymous senders can't fill the log, push older rows out of the telemetry table
(which keeps its newest 50,000 rows), or crowd out a signed-in user's reports.
The counter counts every report, as reported. Anyone who can reach the hub can
send one, so treat a rising rate as a reason to read the log lines' client_ip
and actor, not as proof on its own.
A violation on the dashboard means either a control the dispatcher doesn't route (a button that does nothing) or injected markup that the policy stopped. Both are defects to fix, so alert on any rate.
The policy doesn't use report-to. A browser that supports it ignores
report-uri when both are present, and delivers through the Reporting API in
batches, later, and only to an endpoint it considers trustworthy, so a hub
reached over plain HTTP on a LAN address would get nothing. In a test with
Chrome 151, report-uri reports arrived at once, and report-to reports didn't
arrive at all within ninety seconds.
Two dashboards in one directory¶
A hub reads .cloop/config.yaml from its working directory. When two hubs share
that directory (for example a stable dashboard and a development one), both read
everything in that file, including settings that can only apply to one of
them:
# /etc/systemd/system/cloop-ui.service
ExecStart=/usr/local/bin/cloop ui --port 8080 # reachable at :1234
# /etc/systemd/system/cloop-ui-latest.service
ExecStart=/usr/local/bin/cloop-latest ui --port 8081 # reachable at :8888
ui.oidc.redirect_url names one origin. If you configure SSO for the second hub
in the shared file, the first one starts sending people to a callback it doesn't
serve. The result is an endless sign-in loop or, on a binary older than the
public-client support in ui.oidc, a hub that refuses to start.
So cloop ui --port N reads .cloop/config.ui-N.yaml after config.yaml and
merges it on top. The file is named after the listen port because the port
already distinguishes the two hubs. When an overlay applies, startup names it:
# /srv/app/.cloop/config.ui-8081.yaml — read only by the hub on :8081
ui:
oidc:
enabled: true
issuer: https://login.microsoftonline.com/contoso.onmicrosoft.com/v2.0
client_id: 00000000-0000-0000-0000-000000000000
redirect_url: https://hub.example.com:8888/auth/oidc
default_role: none
admin_emails: [ops@example.com]
- Merging is per key. A key in the overlay replaces the value from
config.yaml; a key missing from the overlay leaves the project's value unchanged. Lists replace instead of appending: ascopeslist in the overlay means those scopes only, not those plus whateverconfig.yamllists. - Limits still apply. Overlay values are clamped like
config.yamlvalues, so moving a setting to the overlay doesn't get around a limit. - A parse error is fatal, as it is in
config.yaml, because the file decides whether anyone has to sign in. - Only
cloop uireads it. Put settings that describe this hub in it: the hub-scope keys below. Leave API keys, budgets, the provider and the model inconfig.yaml, where every other command reads them.cloop runnever reads an overlay, so a provider set there would have no effect on any run. - The Settings panel writes to the overlay. Once an overlay exists, the
panels that save hub settings (
PUT /api/config/oidc,/api/config/telemetry,/api/ci/configand/api/config/stt) write the keys they edit into the overlay. They keep the rest of that file and its comments, and leaveconfig.yamlunchanged. Without an overlay they writeconfig.yaml. - Give it mode
0600: it can hold a client secret, andcloop uiwarns if it is readable by anyone else.
Hub-scope keys. cloop ui reads each of these with its overlay merged in:
| Key | What the hub does with it |
|---|---|
executors.allow_host_process |
Whether a harness may run on this host at all. false in the overlay puts this hub in strict mode; true there relaxes a shared config.yaml that says false, for this hub only. |
executors.min_agent_build, executors.limits, executors.isolation_evidence, executors.image_provenance |
The oldest agent build the hub will place work on, the fleet resource ceiling, whether an executor whose isolation claim was never verified is refused, and whether one of cloop's own images with an unverified signature is refused. As with allow_host_process, a value set in the overlay replaces the one in config.yaml. Once applied, nothing the process reads later can loosen any of them. |
executors.container, executors.kubernetes, executors.orphan_sweep_interval_minutes |
Which isolating drivers the hub registers at startup, and how often it sweeps their orphans. |
executors.git_proxy, executors.kube_guard |
The git interception proxy and the Kubernetes access monitor, started at startup. |
executors.host_sandbox |
Whose runs on this host run in the host sandbox, read at every dispatch, so an edit applies to the next run. |
executors.egress |
The egress proxy, bound at startup. In a cluster each member binds its own, so listen_addr belongs here when two members share a host. |
executors.auto_install_harness |
Whether a device may be asked to install a missing harness. Read on each dispatch. |
executors.feature_bundle_mb |
The cap on a feature's branch and returned work when it runs on an isolating executor. Read at startup. |
executors.failover |
max_attempts, the cap on re-dispatching a run whose executor went unreachable. Read at every failover. |
executors.remote |
The memory remote executors' returned work may occupy on this hub: one executor's budget and every executor's ceiling. Read at startup. |
executors.write_back |
Whether a run on an executor that does not share this hub's filesystem pushes its work back to the project's origin. Read on each dispatch. |
executors.harness_credential_exempt |
Executors whose harness brings its own Claude credential, so a dispatch to them is not refused for want of a granted one. Read on each dispatch. |
executors.model_relay |
The model relay for sandboxed runs. Started at startup with enabled, listener and upstream_base_url; models, max_output_tokens and hub_credential read on each dispatch. |
sandbox.image_policy |
The image trust policy. The hub checks a project's image against it before dispatch, and each driver takes its own copy at startup. |
ui.* |
Where it listens (listen, allow_unauthenticated_network, read at startup), sign-in, TLS, origins, WebSocket caps, quotas, clustering, CI federation, telemetry, resuming capped runs. |
stt |
Dictation settings and key. A project's own stt section still overrides them for requests about that project. |
retention, audit |
The janitor's policy for the hub's own directory. Every other project keeps its own. audit.checkpoints is the hub's alone, read at every checkpoint window. |
backup |
Auto-backup of the hub's own directory. |
github.token |
The token the hub hands a pull request that runs on its own host. |
Settings read at startup (the drivers, the git proxy, the Kubernetes monitor, the egress proxy, the executor ratchets) take effect at the next restart, whichever file they are in. The rest are read on each use.
cloop hub doctor --port N diagnoses the hub on port N with its overlay
merged. Without --port, it diagnoses the hub that a plain cloop ui starts
(port 8080, with config.ui-8080.yaml if there is one), and --port 0 reads
config.yaml alone. It lists any overlay it didn't merge.
The overlay is a separate file, not a port-keyed section of config.yaml,
because an older binary sharing the directory doesn't ignore a key it doesn't
know: it drops it the next time anything calls Save(). A block that makes
sign-in required must not be deleted that way, and no binary opens a file name
it doesn't know about.
Several hubs, one control plane¶
Hubs that share a directory are members of one
hub cluster: they serve the same state, forward
to each other the requests only one of them can answer, and elect one leader.
ui.cluster says how a member is reached and whether other members are
allowed:
ui:
cluster:
peer_server_name: hub.example.com # verify peers' certificates as this name
peer_ca_file: /etc/cloop/peer-ca.pem # trust this CA for them, too
# exclusive: true # one process, and refuse any other
| Key | Default | Meaning |
|---|---|---|
advertise_url |
http(s)://127.0.0.1:<port> |
Where the other members reach this one. Set it per process: in the instance overlay, or with --advertise-url, CLOOP_CLUSTER_ADVERTISE_URL or CLOOP_CLUSTER_ADVERTISE_HOST (a host only; cloop adds the scheme, brackets an IPv6 address and appends its port), which take precedence in that order. A bare origin: no path, query or credentials. |
peer_server_name |
host of ui.external_url |
The name an https:// peer's certificate is verified against when the peer is advertised by IP address. |
peer_ca_file |
— | PEM bundle trusted for peers' certificates, in addition to the system roots and this hub's own ui.tls certificate. |
exclusive |
false |
Use the control plane alone: refuse to start while another hub serves it, and make any other hub refuse to start while this one does. |
On one machine the defaults work and nothing needs setting. Forwarded requests
carry the caller's credentials, so members that reach each other over a network
others can observe should advertise https:// URLs; see
the peer channel.
Front-end telemetry¶
The dashboard and the display-glasses page can record a diagnostic trail (the
gestures they received, the views they opened, the requests they sent and any
errors) and post it to the hub, where cloop hub telemetry and the Telemetry
tab show it. It never leaves the deployment.
Collection is off by default. Nothing is sent, and each front end asks the hub before it sends anything.
ui:
telemetry:
enabled: true # off unless you set this
sources: [glasses] # optional; omit for every front end
| Key | Default | What it does |
|---|---|---|
enabled |
false |
The main switch. While it is off, both ingest routes answer 404 instead of discarding data without notice, so you can verify the setting, and the front ends don't post anything. |
sources |
all | Restricts collection to the named front ends: dashboard, glasses. An empty or absent list means all of them. |
It is off by default because a trail contains the URLs someone visited, the views they opened, their user agent and their address, and collecting that is a decision each deployment should make for itself.
Turn it on in Settings → Telemetry in the dashboard. It takes effect on the
next page load, needs no restart, and leaves a telemetry.config.updated audit
row naming who turned it on. You can also edit this block by hand. The setting
describes the hub, so where two hubs share a directory it belongs in that hub's
.cloop/config.ui-<port>.yaml, which is
where the panel saves it once the file exists.
sources: [glasses] is a useful setting: the display-glasses page has no
console and no network inspector, so it can't be debugged any other way, while
the dashboard produces most of the volume and most of the privacy cost.
While collection is on, credentials are scrubbed before storage and the table
keeps at most 50,000 rows. Turning collection off stops new events but deletes
none; remove what was already collected with cloop hub telemetry prune. See
front-end telemetry.
Resuming a capped run¶
A run stopped by a Claude Code subscription cap records when the usage window it
hit reopens, because the OAuth usage API reports each window's reset time. This
covers both kinds of cap: a configured claudecode.max_*_pct, and a window used
up entirely while budget.block_extra_usage (on by default) keeps the run from
going into extra usage. If the window reopens within 90 minutes, the run waits
and then continues. Otherwise the run pauses, shown as Paused: 5-hour window
exhausted, resumes 23:30 in the dashboard. The hub checks every five minutes
and restarts such runs once the reset time has passed.
A task that the harness cuts off at the limit, ending with "You've hit your session limit · resets 11:30pm (UTC)", goes back to pending instead of being recorded as done. The run retries it after the reset time in the message, read in the time zone the message gives. An auto-evolve round is held the same way, both before it starts and when the provider answers it with a limit message, so a plan that runs out of tasks as its window runs out pauses instead of ending as complete.
| Key | Default | What it does |
|---|---|---|
auto_resume_on_cap_reset |
true |
When false, a run paused by a cap stays paused until someone presses Run. The pause reason and its resumes_at are recorded either way, so the dashboard still shows when the window reopened. |
Only a usage cap is resumed this way, and only once its reset time has passed. A run paused by a declined approval, a spent budget, an operator's Stop or a rejected credential is never restarted on a timer, because each of those is a decision a resume would override, and none of them ends by itself. A cap whose reset time the API didn't report is also left alone, because nothing says the limit has lifted.
Older builds recorded the extra-usage hold as a spent budget with no reset time: "Claude Code 5-hour window exhausted (100%) — pausing to avoid extra usage …". A run keeps the build it started with, and an edge device runs its own build, so such pauses can still appear after the hub is upgraded. The hub recognises that wording and restarts the run once the usage API shows no exhausted window. Every other budget pause stays paused.
Whose account a resume runs on. On a hub with single sign-on, a resumed run
acts for the person who started the original run (recorded in the hub's own
database when that run was dispatched, or the project's owner for a run older
than that record), and runs on that person's Claude account. On an executor that
shares the hub's filesystem, it uses their own Claude home with the hub's
ambient token cleared, as when they pressed Run. On an isolating executor, it
uses the credential the project is granted for them. The older builds' pauses
described above are judged by that person's own usage windows, not the hub's. A
resume never creates a Claude home and never falls back to the hub's own login.
If the person's home is gone (they were offboarded, or never signed in here),
the hub denies them, or nobody can be named (the hub can't name anyone for a run
started from a shell with cloop run), the run stays paused as starter's
Claude account unavailable, naming them, and the project's Event History says
why. Press Run to start it as yourself. A run started by a token that acts
for no one (the static bearer token, a service token) ran on the hub's own login
and resumes on it. A failover's replacement run follows the same rules. See
resumed and failed-over runs.
It is on by default because otherwise an unattended fleet would stay paused for the rest of a weekly window over a limit that had already lifted, at a time the hub already knew.
Hub health cadence¶
Each hub member runs cloop hub doctor's checks on itself for
Settings → Hub health and its header indicator, and pushes a summary of each
report to the dashboards of the administrators connected to it. It reports at a
regular interval, and sooner when one of its configuration files changes, when
an executor, a hub CA rotation step or a sealing key changes, and when someone
presses Run the checks again. Open dashboards don't cause extra reports: a
page asks for the report only when its socket opens.
| Key | Default | What it does |
|---|---|---|
hub_health_interval |
5m |
How often a member reports on itself when nothing else triggered a report. A shorter interval shows a certificate nearing expiry or a filling disk sooner, at the cost of one doctor run per interval per member. A value that doesn't parse, or is outside [1m, 1h], runs at the default, and config.repaired says so. |
This is per process: where several hub processes share a directory, a member can
set its own value in its config.ui-<port>.yaml overlay. See Hub health in the
dashboard.
TLS¶
Serving. cloop ui and cloop serve can terminate TLS themselves, or run
behind a proxy that does. A partial configuration (a certificate without a key,
or the reverse) fails at startup instead of falling back to plaintext, because
a server that serves HTTP after being asked for HTTPS might never be noticed.
ui:
external_url: https://cloop.example.com # one of the hub's own origins
allowed_origins: [ops.example.com] # deployment-wide (dashboard + agents)
allowed_ws_origins: [legacy.example.com] # dashboard socket only
trusted_proxies: [10.42.0.0/16] # whose X-Forwarded-* to believe (loopback always)
allowed_hosts: [devbox.lan] # names a hub WITHOUT sign-in answers to
tls:
cert_file: /etc/cloop/tls/fullchain.pem
key_file: /etc/cloop/tls/privkey.pem # mode 0600
min_version: "1.2" # or "1.3"; 1.0/1.1 are rejected
Or per invocation: cloop ui --tls-cert <pem> --tls-key <pem> (the two flags
together override the config block). Only AEAD cipher suites with forward
secrecy are offered: no CBC and no static RSA. For local development,
cloop hub tls-init generates a self-signed pair (the key is written with mode
0600 through pkg/atomicfile, so it is never readable by others, even briefly)
and prints the pin to give to devices.
Proxies: ui.trusted_proxies. Behind a reverse proxy, the hub can't see
the browser's scheme, the host name it used (when the proxy rewrites Host), or
the client's address. The proxy passes them in X-Forwarded-Proto,
X-Forwarded-Host and X-Forwarded-For, but any client can send those headers
too. So the hub trusts them only from a peer on loopback (a proxy on this
machine, always trusted) or inside a prefix of ui.trusted_proxies (CIDR
prefixes or addresses; one covering every address, 0.0.0.0/0 or ::/0, is
refused at startup), and ignores them from every other peer. Older builds also
trusted X-Forwarded-Proto from any peer once ui.external_url was https, and
read X-Forwarded-For from its leftmost entry, which a proxy that appends
(nginx's $proxy_add_x_forwarded_for) passes through from the client, so anyone
could pick a new address to get around the rate limiter and the sign-in lockout.
X-Forwarded-For is now read from the right, skipping trusted proxies, up to
the first address that isn't one.
Add a TLS terminator on another host (an Ingress controller, a load balancer) to
trusted_proxies. Otherwise the hub treats that proxy's requests as plaintext:
it refuses /install.sh, offers no one-line installer at enrollment, builds
ws:// agent URLs, and checks a browser without Sec-Fetch-Site against an
http:// origin (with external_url set, it still matches). The same list
applies to cloop serve. Members of a hub cluster need no entry, because a
request forwarded by another member is signed and carries the browser's scheme
and host with it.
HSTS and cookies. Responses delivered over TLS (by the hub's own listener,
or with a trusted proxy's X-Forwarded-Proto: https) carry
Strict-Transport-Security: max-age=31536000; includeSubDomains. So does every
response from a hub whose public URL (ui.external_url, otherwise
ui.oidc.redirect_url) is https: the operator has declared the deployment
HTTPS, and a browser ignores the header on a response that arrived over
plaintext. Under cookie_secure: auto, the OIDC session cookie becomes
Secure + SameSite=Strict when the request arrived over TLS by the same rule,
or when ui.oidc.redirect_url is https, because the cookie is set on the
response to that URL and so reached the browser over TLS whatever a proxy said.
Otherwise neither is applied on plaintext: HSTS on http://localhost would
force a developer's browser to use HTTPS there for a year and break every other
local project on that host name.
The hub's own origins. Several checks below ask whether a browser's request
came from one of the hub's own pages. The hub's own origins, matched exactly by
scheme, host and port, are: the origin the request was addressed to (its scheme
and Host, or a trusted proxy's X-Forwarded-Proto and X-Forwarded-Host),
ui.external_url, the origin of ui.oidc.redirect_url, and
ui.allowed_origins (plus ui.allowed_ws_origins for the dashboard socket). An
entry is a full origin (https://ops.example.com, http://hub.lan:8081) or
host[:port], which means https. An http origin must be written out in full,
because anyone on the network path can serve one. An entry that doesn't parse
matches nothing and is named in a warning at startup. Loopback isn't treated
specially: a page on http://localhost:3000 is a different origin from a hub on
:8080, and so is another port on the hub's own host name.
State-changing requests. A POST, PUT, PATCH or DELETE (any method
except GET, HEAD and OPTIONS) is refused with 403 CROSS_ORIGIN unless it
carries an Authorization: Bearer credential (API tokens, the static token, the
CI relay, executor agents; a page on another origin can attach one only after a
CORS preflight, and the hub allows none), or:
- the browser's
Sec-Fetch-Site(which every current browser sends and no page can set) issame-originornone.same-site(another port on the hub's host, a sibling subdomain) andcross-siteare refused, whatever else the request says; - without
Sec-Fetch-Site, itsOriginis absent (not a browser) or exactly one of the hub's own origins.
This applies on every hub, with or without sign-in: a hub with no credential has
nothing for SameSite to protect, and an authenticating proxy's own cookie or
Basic credential is sent along like a session cookie. The request body, if
it has a Content-Type, must be application/json (multipart/form-data on
the three upload routes, /api/voice, /api/transcribe and
/api/glasses/transcribe), or the request is refused with 415
UNSUPPORTED_MEDIA_TYPE. Without a preflight, a form or a fetch(…,
{mode: 'no-cors'}) on another origin can send only form data and text/plain.
Every handler reads its JSON body through one decoder (pkg/jsonbody) that
refuses the same, and a body with no Content-Type. The Anthropic relay under
/api/ci/anthropic/ passes bodies through unread, and is exempt from the media
type check only. No route accepts a cross-site write. The only one that would
need to, an OIDC callback with response_mode=form_post, doesn't exist, because
the hub's callback is a GET.
A refusal names ui.allowed_origins and the origin the hub thinks the request
was addressed to, is counted in cloop_cross_origin_refusals_total and is
audited as request.origin_refused (at most 30 rows a minute). The hub sends no
CORS headers to any origin.
Host names a hub without sign-in answers to: ui.allowed_hosts. On a hub
with neither SSO nor a static token, a request whose Host (or trusted
X-Forwarded-Host) is not one the hub answers to gets 421
MISDIRECTED_REQUEST. DNS rebinding points a name the attacker controls at
127.0.0.1, which makes a page on that name same-origin with a loopback hub, so
every check above would correctly report "same-origin". The hub therefore
refuses the name itself. It answers to localhost and *.localhost, any IP
address (a browser sends an IP-literal Host only to a page whose own origin is
that address), ui.external_url's host, the advertise hosts of its cluster
members, and the entries of ui.allowed_hosts (a host name, any port, or
host:port). /healthz and /readyz answer any name. A hub with sign-in
doesn't check, because a rebound name carries none of its credentials. Refusals
are counted (reason="unknown_host") and audited as request.host_refused.
WebSocket origins. Both the dashboard socket and the executor-agent endpoint
accept an upgrade only from an Origin that is exactly one of the hub's own
(see above). A request with no Origin is allowed, because every non-browser
agent sends none and a browser can't leave the header out. A cross-origin
upgrade gets 403 with the reason before any token is examined, so it can't use
up a single-use enrollment token on the way to being refused.
The two allowlists differ in what they expose and are kept separate:
allowed_origins is deployment-wide and reaches /api/executors/connect, where
an entry can open an agent connection; allowed_ws_origins applies to the
dashboard socket only. Prefer setting external_url, which covers the
reverse-proxy case without either list. Older builds also admitted every
loopback origin and every port of the hub's host name on both endpoints.
Outbound. All three remote providers (Anthropic, OpenAI, custom
OpenAI-compatible) and the executor agent validate certificates against the
system CA pool. cloop has no InsecureSkipVerify option, and
tests/security/transport_test.go fails the build if one appears on the
agent's dial path. To reach a server with a private CA, add a root
(--ca-file) instead of turning verification off.
For self-hosted models on plain HTTP (Ollama), set ollama.base_url to the
local endpoint. Outbound traffic to Anthropic and OpenAI always uses TLS.
Shell hooks and command injection¶
Pre- and post-task hooks (hooks.pre_task, hooks.post_task, etc.) are shell
commands that users configure in .cloop/config.yaml. Task context (title,
status, role) is passed to them as environment variables and never inserted
into the hook command string, so task content can't inject shell commands
through a hook.
Dependency security¶
cloop uses govulncheck (golang.org/x/vuln) for dependency audits. The
toolchain directive in go.mod pins the minimum Go version to one that fixes
all known standard library vulnerabilities.