config

package
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package config manages the .cloop/config.yaml project configuration file.

Storage model (Task 20075): the canonical source remains the human-readable .cloop/config.yaml file, but every Save() also mirrors the serialised YAML into a metadata row of the project's SQLite state.db (when one exists). This makes config queryable alongside cost and step data, and provides a recovery fallback if the YAML file is lost or quarantined for corruption. Load() prefers YAML when present; if YAML is missing, it transparently rehydrates from the SQLite mirror so the project keeps working.

executorlimits.go is the operator's side of the resource ceiling: the `executors.limits` section of config.yaml.

It exists because every resource knob cloop had before it was a *default*. `executors.container.memory: 2g` is what a workload gets when it asks for nothing, and the container driver hands the decision to the workload the moment it does ask — "spec limits override the executor's configured defaults". For a request written by the operator that is right. For one read out of .cloop/sandbox.yaml, a file committed to the repository, it means the project sets its own limits and the operator's number was advice.

These keys are not advice. See pkg/executor/ceiling.go for the semantics; this file is only the parsing, the validation and the units.

Index

Constants

View Source
const (
	// MaxParallel: 1..MaxParallelUpper. Zero in YAML means "not set"
	// (omitempty zero value) and stays untouched — runtime treats 0 as
	// "use default". Anything else outside the range is pathological:
	// negative spawns no workers; an absurdly large value would create
	// thousands of goroutines per parallel tick.
	MaxParallelLower = 1
	MaxParallelUpper = 64

	// Rate limiter: zero means "use HTTP server default", but if the user
	// sets a value it must be a sane positive rate / burst.
	RateLimitRPSLower   = 1.0
	RateLimitBurstLower = 1

	// Budget alert threshold percent must be 0..100. 0 is allowed and
	// disables alerting; >100 is meaningless.
	AlertThresholdMin = 0
	AlertThresholdMax = 100

	// WebSocket connection caps for the cloop ui server (Task 20090).
	// Zero in YAML means "not set" — runtime substitutes the *Default*
	// values below. Non-zero values outside the allowed band are clamped
	// back to zero (validateAndClamp) and rejected by `cloop config set`
	// (ValidateNumeric) to keep the goroutine pool bounded.
	//
	// Upper bounds are intentionally generous: 4096 total connections is
	// far above any realistic single-tenant dashboard load (a browser
	// opens at most a handful of WebSocket peers per tab) but below the
	// default Linux open-file ulimit (1024 → 4096 with `ulimit -n`).
	// Per-IP cap of 1024 is similarly headroom-laden but stops a single
	// misbehaving client from monopolising the total budget.
	WebSocketConnsLower        = 1
	WebSocketConnsUpper        = 4096
	WebSocketConnsPerIPLower   = 1
	WebSocketConnsPerIPUpper   = 1024
	WebSocketConnsDefault      = 256
	WebSocketConnsPerIPDefault = 8

	// Audit-trail retention window in days (Task 20218). Zero in YAML means
	// "keep everything", which is the pre-Task-20218 behaviour and stays the
	// default — see AuditConfig for why retention on a compliance record is
	// opt-in. Non-zero values outside the band are clamped back to zero
	// (validateAndClamp) and rejected by `cloop config set` (ValidateNumeric).
	//
	// Bounds: one day lower, because a sub-day window would prune rows a
	// running incident investigation is still reading. Ten years upper,
	// which is past every retention schedule cloop is likely to meet and
	// exists to catch a units mix-up (hours or minutes typed into a field
	// counted in days) rather than to express a policy.
	AuditRetentionDaysLower = 1
	AuditRetentionDaysUpper = 3650

	// Retention janitor bounds (Task 20229). Zero in YAML means "use the
	// package default" for every one of these — see RetentionConfig for why
	// this section defaults on where audit retention defaults off. Values
	// outside the band are clamped back to zero (validateAndClamp), which
	// restores the default rather than disabling the step: a mis-typed
	// interval should not silently stop a hub reclaiming its disk.
	//
	// Interval: one hour lower, because a pass walks the whole .cloop tree
	// and running it more often than that costs more I/O than the growth it
	// would find. A fortnight upper, past which an operator is not really
	// running a janitor and should say `enabled: false` instead.
	RetentionIntervalHoursLower = 1
	RetentionIntervalHoursUpper = 336

	// Keep-count: one lower, because keeping zero snapshots is expressed by
	// disabling the step, not by a keep-count that would delete the snapshot
	// written a moment ago. 10,000 upper to catch a units mix-up; the
	// directory this bounds reached 3,983 files before anything bounded it.
	RetentionKeepSnapshotsLower = 1
	RetentionKeepSnapshotsUpper = 10000

	// Archive limits, in MiB and days. Both are opt-in (zero keeps
	// everything); the bands only constrain a value an operator did set.
	RetentionArchiveMaxMBLower      = 1
	RetentionArchiveMaxMBUpper      = 1 << 20 // 1 TiB
	RetentionArchiveMaxAgeDaysLower = 1
	RetentionArchiveMaxAgeDaysUpper = 3650

	// Vacuum threshold: a fraction of the file, so the band is 0..1. The
	// lower bound is 0.01 rather than 0 because 0 means "unset, use the
	// default" — an operator who genuinely wants to vacuum unconditionally
	// is asking to rewrite a multi-gigabyte file daily, and 1% is close
	// enough to that while still being a threshold. 1 or more disables
	// vacuuming and is accepted, not clamped.
	RetentionVacuumFreeRatioLower = 0.01

	// Absolute floor beneath the ratio, in MiB. 1 MiB lower; 1 TiB upper to
	// catch a units mix-up.
	RetentionVacuumMinFreeMBLower = 1
	RetentionVacuumMinFreeMBUpper = 1 << 20

	// Ceiling on the live data an in-process VACUUM will rewrite, in MiB.
	// Same band as the floor above; the default lives in pkg/janitor.
	RetentionVacuumMaxInlineMBLower = 1
	RetentionVacuumMaxInlineMBUpper = 1 << 20

	// Row-table retention (Task 20291). These share the zero-means-default
	// convention above and add one value to it: RetentionKeepEverything opts a
	// single table out. The opt-out is needed because these limits, unlike the
	// archive's, are on by default — so an operator who wants a table kept in
	// full has to have a way to say so that is not "disable the janitor".
	RetentionProviderCallBodyAgeDaysLower = 1
	RetentionProviderCallBodyAgeDaysUpper = 3650
	RetentionTelemetryMaxAgeDaysLower     = 1
	RetentionTelemetryMaxAgeDaysUpper     = 3650

	// RetentionRowsLower is the floor every row ceiling shares. Below a
	// hundred, a ceiling stops bounding a table and starts emptying it, and a
	// mistyped `10` would silently discard a project's history on the next
	// pass.
	RetentionRowsLower = 100
	RetentionRowsUpper = 100_000_000

	// RetentionKeepEverything disables one row limit while leaving the rest of
	// the retention policy in force.
	RetentionKeepEverything = -1

	// HTTP request body cap for the cloop ui and cloop serve servers
	// (Task 20102). The cap protects against memory-exhaustion DoS via
	// oversized POST/PUT/PATCH payloads on the long-running daemon. Zero
	// in YAML means "not set" — runtime substitutes MaxRequestBodyBytesDefault.
	// Non-zero values outside the allowed band are clamped back to zero
	// (validateAndClamp) and rejected by `cloop config set` (ValidateNumeric).
	//
	// Bounds: 1 KiB lower (anything smaller would reject normal task PATCHes)
	// and 1 GiB upper (anything larger defeats the protection while being
	// well above any legitimate JSON payload). Default 10 MiB covers chat
	// transcripts and large bulk-edit JSON arrays with comfortable headroom.
	MaxRequestBodyBytesLower   int64 = 1 << 10  // 1 KiB
	MaxRequestBodyBytesUpper   int64 = 1 << 30  // 1 GiB
	MaxRequestBodyBytesDefault int64 = 10 << 20 // 10 MiB

	// Orchestrator per-task wall-clock timeout bounds. Zero in YAML means no
	// task timeout (Task 20148); a positive value is an explicit opt-in that
	// must fall within [Lower, Upper] (otherwise it is treated as 0 / no
	// timeout). OrchestratorTaskTimeoutMinutesDefault is retained only for
	// validation error messages and is no longer applied as a fallback.
	//
	// Lower bound is 1 minute: anything smaller would race the AI's first
	// token on every call. Upper bound is one week (7*24*60), well above
	// any legitimate single-task budget but a hard ceiling against an
	// operator hand-editing nonsense like "1000000 minutes".
	OrchestratorTaskTimeoutMinutesLower   = 1
	OrchestratorTaskTimeoutMinutesUpper   = 7 * 24 * 60
	OrchestratorTaskTimeoutMinutesDefault = 30

	// Container executor limits (Task 20157). Zero means "no limit
	// requested" for every one of these; a non-zero value must be usable.
	//
	// The upper bounds are not a guess at what a host has — they are a
	// guard against a typo becoming a silent non-limit. A container asked
	// for 100000 CPUs does not get them; the runtime clamps or errors, and
	// the operator believes a cap is in force that is not. Refusing the
	// value is the only outcome that keeps config honest.
	ContainerCPUsUpper = 1024.0
	// Memory floor: below ~64 MiB a Go binary cannot even start, so a
	// smaller cap would make every run fail with an unrelated-looking OOM.
	ContainerMemoryMBLower = 64
	ContainerMemoryMBUpper = 1 << 20 // 1 TiB
	// PID cap: 1 is enough for a single process; the ceiling is well above
	// any build's fan-out but below the kernel's default pid_max.
	ContainerPIDsLower = 1
	ContainerPIDsUpper = 1 << 16
	// Workspace/scratch disk bounds (Task 20179). Deliberately the same
	// numbers as the memory pair rather than a second opinion about what a
	// machine has: both bound a repo-committed request against a ceiling that
	// exists to stop a typo becoming a silent non-limit, and two different
	// sets would only invite the question of which one is authoritative.
	//
	// The floor is an error rather than a clamp, for the same reason the
	// memory floor is: clamping *up* would hand the project more than it
	// asked for, which is the direction a sandbox spec never moves in. Below
	// it, a checkout plus the harness's own scratch does not fit, and the run
	// fails on the fetch with a message about the repository rather than
	// about the limit that actually refused it.
	ContainerDiskMBLower = 64
	ContainerDiskMBUpper = 1 << 20 // 1 TiB

	// Kubernetes executor bounds (Task 20161).
	//
	// One week, matching OrchestratorTaskTimeoutMinutesUpper. Every duration
	// in the section is a deadline or a grace period, and none of them has a
	// legitimate value beyond that; a larger number is a units mistake
	// (milliseconds typed into a seconds field) that would otherwise become
	// an effectively-unbounded Pod.
	KubernetesSecondsUpper = 7 * 24 * 60 * 60
	// Concurrency ceiling. Not a claim about cluster capacity — the
	// namespace's ResourceQuota is that — but a guard against a runaway loop
	// asking for a Pod count no operator typed on purpose.
	KubernetesMaxConcurrentUpper = 1024

	// Egress broker bounds (Task 20163).
	//
	// A proxy session is a live credential, so its ceiling is the security
	// control and not a convenience. Four hours is already generous for
	// something whose whole point is to be short-lived; beyond that the value
	// is not a lease, it is a configuration, and the broker's own
	// MaxSessionTTLCeiling refuses it a second time.
	EgressMaxSessionMinutesUpper = 4 * 60
	// Dial ceiling. A destination that has not answered in five minutes is
	// not going to, and a larger value only holds a sandbox's request open
	// while it waits to fail.
	EgressDialTimeoutSecondsUpper = 300
)

Bounds for numeric config values. Centralised here so both Load()'s warn-and-clamp path and `cloop config set`'s reject path agree on the same limits, and so pkg/configvalidate can import them without hard-coding constants in three places.

View Source
const (
	// CIClockSkewDefault and CIClockSkewMax bound CIConfig.ClockSkewSeconds.
	CIClockSkewDefault = 60
	CIClockSkewMax     = 300

	// CIExchangeKeepDefault and CIExchangeKeepMax bound the exchange log.
	CIExchangeKeepDefault = 200
	CIExchangeKeepMax     = 500
)

CI federation bounds and defaults.

View Source
const (
	OIDCSessionTTLHoursDefault = 24
	OIDCSessionTTLHoursLower   = 1
	OIDCSessionTTLHoursUpper   = 720
)

OIDC session TTL bounds (hours).

View Source
const (
	OIDCIdleTimeoutHoursDefault = 8
	OIDCIdleTimeoutHoursLower   = 1
	OIDCIdleTimeoutHoursUpper   = 720
)

OIDC idle-timeout bounds (hours). The upper bound matches the absolute TTL's because the effective value is clamped to it anyway.

View Source
const (
	OIDCRefreshIntervalMinutesDefault = 15
	OIDCRefreshIntervalMinutesLower   = 1
	OIDCRefreshIntervalMinutesUpper   = 1440
)

OIDC IdP-revalidation bounds (minutes).

The lower bound is one minute rather than zero seconds: a hub that asked the provider about every session every few seconds would be indistinguishable from a denial-of-service against its own IdP. The upper bound is a day, beyond which the check no longer meaningfully bounds revocation lag.

View Source
const (
	OIDCMaxClaimAgeMinutesDefault = 5
	OIDCMaxClaimAgeMinutesLower   = 1
	OIDCMaxClaimAgeMinutesUpper   = 60
)

OIDC claim-freshness bounds (minutes). The upper bound mirrors oidcauth.MaxMaxClaimAge; unlike clock skew it is clamped here rather than refused, because an operator who wrote a day plainly wanted the check relaxed and -1 says that exactly — there is no interpretation under which refusing to start the hub helps them.

View Source
const (
	OIDCClockSkewSecondsDefault = 300
	OIDCClockSkewSecondsUpper   = 600
)

OIDC ID-token clock-skew bounds (seconds).

The upper bound mirrors oidcauth.MaxClockSkew. It is not enforced here: pkg/oidcauth validates it and fails startup, which is the same split role_mappings has with authz.New — one place decides what is well-formed, and this package stays free of the reasoning about why.

View Source
const (
	BackgroundGraceSecondsMax = 120
	BackgroundWaitMinutesMax  = 24 * 60
)

Bounds for BackgroundConfig, applied by Clamp. A grace window longer than a couple of minutes would be paid by every task that leaves anything behind, and a wait measured in days would hang a project on one runaway process.

View Source
const EnvOIDCClientSecret = "CLOOP_OIDC_CLIENT_SECRET"

EnvOIDCClientSecret is the environment variable that supplies the OIDC client secret, overriding ui.oidc.client_secret.

Named rather than repeated because two things have to agree about it: Load, which applies the override, and `cloop hub doctor`, which reports a hub whose secret came from the config file instead — and the whole point of the override is that config.yaml is the file an operator commits.

View Source
const GitProxySessionMinutesUpper = int(gitproxy.MaxSessionTTL / 60e9)

GitProxySessionMinutesUpper bounds a git-proxy session.

A session is a live credential for one repository, so its ceiling is a security control rather than a convenience. Twelve hours is gitproxy.MaxSessionTTL, and the reasoning there applies here: a git-proxy session that lives longer than a working day is indistinguishable from having handed the sandbox the forge credential outright.

View Source
const KubeGuardSessionMinutesUpper = int(kubeguard.MaxSessionTTL / 60e9)

KubeGuardSessionMinutesUpper bounds a monitor session, for the reason kubeguard.MaxSessionTTL gives: a session that outlives a working day is indistinguishable from having handed the sandbox the kubeconfig.

Variables

View Source
var DefaultCIModels = []string{
	"claude-sonnet-*",
	"claude-haiku-*",
}

DefaultCIModels is the model allowlist a rule inherits when it names none.

It is a conservative default rather than a permissive one: a pipeline that has been lent the hub's credential should reach the models an agent harness actually needs, and an operator who wants the largest model available to CI should have to say so. The patterns are globs so a point release does not silently lock every pipeline out.

View Source
var ErrMemoryTooLarge = ErrSizeTooLarge

ErrMemoryTooLarge is the name this sentinel had when memory was the only size in the config. It is retained as an alias rather than renamed at every call site because it is exported and matched with errors.Is; both names refer to the same value, so a caller written against either keeps working.

View Source
var ErrSizeTooLarge = errors.New("config: size exceeds the maximum")

ErrSizeTooLarge is returned when a size string parses but exceeds its ceiling. It is a sentinel so the Clamp helpers can tell "too big" from "malformed" without matching on message text — the two need opposite treatments, and a refactor of the wording must not silently swap them.

Functions

func ClampDiskMB

func ClampDiskMB(s string) (mb int, clamped bool, err error)

ClampDiskMB is ClampMemoryMB for the disk ceiling: the workspace/scratch budget a project's .cloop/sandbox.yaml asks for.

It clamps for exactly the same reason. The author of a repo-committed file does not know the hub's ceiling, and a run that will not start until they guess an acceptable number is worse than one that runs at the limit and says so in a warning.

func ClampMemoryMB

func ClampMemoryMB(s string) (mb int, clamped bool, err error)

ClampMemoryMB parses a memory size and pins it to the supported range, reporting whether it had to.

It exists for repo-committed input (.cloop/sandbox.yaml), where the author does not know the hub's ceiling and should not have to. ParseMemoryMB errors above the ceiling, which is right for an operator editing the hub's own config — they chose the number and can choose another. It is wrong for a tenant, whose run would simply not start until they guessed a value the deployment happens to accept.

Only the over-ceiling case is clamped. A malformed size is still an error: "2gb" is a typo, not an ambitious request, and quietly substituting a number for it would run the sandbox with a limit nobody wrote.

func ConfigPath

func ConfigPath(workdir string) string

ConfigPath returns the path to the config file.

func ExecutorLimitWarnings added in v0.0.2

func ExecutorLimitWarnings(l ExecutorLimitsConfig) []string

ExecutorLimitWarnings surfaces a hand-edited ceiling that cannot be parsed.

Refusing to boot would be a denial of service over a typo — the treatment Load gives every other malformed value here. But an unparseable ceiling is silently no ceiling, and "the deployment believes it is capped and is not" is exactly the state min_agent_build's banner exists to prevent. So it loads, and it says so loudly.

func ExecutorWarnings

func ExecutorWarnings(e ExecutorsConfig) []string

ExecutorWarnings returns advisory messages about a valid-but-questionable executors section: configurations that will load and run, but that an operator probably did not intend.

func ParseDiskMB

func ParseDiskMB(s string) (int, error)

ParseDiskMB converts a size string to megabytes against the disk ceiling.

It is the same grammar as ParseMemoryMB — one parser, so "2g" cannot mean two different things in two neighbouring keys of the same file — with a different bound and a different word in the error, because the reader of "disk 900000g exceeds the maximum" should not have to work out which key they got wrong.

func ParseMemoryMB

func ParseMemoryMB(s string) (int, error)

ParseMemoryMB converts a size string to megabytes.

Accepted forms mirror what the container runtimes accept — "512m", "2g", "1048576k", "536870912b" — plus a bare integer, which is read as megabytes. (The runtimes read a bare integer as *bytes*; that reading turns "512" into half a kilobyte and an instantly-OOMing sandbox, so cloop takes the interpretation an operator obviously meant.)

An empty string yields 0, meaning "no limit requested".

func SandboxWarnings

func SandboxWarnings(s SandboxConfig, executorsIsolated bool) []string

SandboxWarnings returns advisory notes about a valid policy that is probably not what the operator meant.

These are warnings and not errors because each describes a deployment that works — a hub with no image policy is the correct default for a laptop, and making it fatal would break every existing install on upgrade.

func Save

func Save(workdir string, cfg *Config) error

Save writes the config to .cloop/config.yaml.

The write is atomic — data is staged in a sibling .tmp file, fsynced, then renamed into place. A crash, ENOSPC, or `cloop config set` racing with a reader can no longer leave the file half-written and lose the user's API keys / provider settings.

func ValidateContainerExecutor

func ValidateContainerExecutor(c ContainerExecutorConfig) error

ValidateContainerExecutor returns a non-nil error describing the first problem found in c. It does not mutate.

It runs even when the executor is disabled: a disabled-but-broken config should be reported when it is written, not discovered months later at the moment someone flips enabled to true.

func ValidateEgressConfig

func ValidateEgressConfig(e EgressConfig) error

ValidateEgressConfig returns a non-nil error describing the first problem found in e. It does not mutate.

Like the other executor validators it runs even when the section is disabled, so a broken value is reported when it is written rather than discovered at the moment someone flips enabled to true.

func ValidateExecutorLimits added in v0.0.2

func ValidateExecutorLimits(l ExecutorLimitsConfig) error

ValidateExecutorLimits rejects a malformed ceiling at the door, for `cloop config set` and `cloop config validate`.

func ValidateExecutors

func ValidateExecutors(e ExecutorsConfig) error

ValidateExecutors checks the whole executors section.

Strict no-host-execution mode with nothing else configured is deliberately NOT an error. It is a legitimate and even expected intermediate state: an operator hardens the control plane first, then enrolls edge devices, and remote executors arrive at runtime over an outbound connection rather than through this file. Making it fatal would mean a hardened control plane refuses to boot until a device happens to be online — precisely backwards for a security control. ExecutorWarnings surfaces it as advice instead, and the Executors tab shows it as a banner.

func ValidateGitProxyConfig

func ValidateGitProxyConfig(g GitProxyConfig) error

ValidateGitProxyConfig returns a non-nil error describing the first problem found in g. It does not mutate.

Like the other executor validators it runs even when the section is disabled, so a broken value is reported when it is written rather than discovered at the moment someone flips enabled to true.

func ValidateKubeGuardConfig added in v0.0.2

func ValidateKubeGuardConfig(k KubeGuardConfig) error

ValidateKubeGuardConfig returns the first problem found in k, without mutating. Like the other executor validators it runs even when the section is disabled, so a broken value is reported when it is written rather than discovered the first time someone turns it on.

func ValidateKubernetesExecutor

func ValidateKubernetesExecutor(k KubernetesExecutorConfig) error

ValidateKubernetesExecutor returns a non-nil error describing the first problem found in k. It does not mutate.

Like the container validator it runs even when the executor is disabled, so a broken section is reported when it is written rather than at the moment someone flips enabled to true.

Note what is *not* validated here: there is no kubeconfig path to check, because this executor has no such setting. Credentials are leased from the secret broker at run time, so the only thing config can say about them is which secret to prefer.

func ValidateSandbox

func ValidateSandbox(s SandboxConfig) error

ValidateSandbox checks the sandbox section.

Like the executor validators it runs even when the policy is inert, so a half-written allowlist is reported when it is saved rather than discovered by the first project whose run it refuses.

func WriteDefault

func WriteDefault(workdir string) error

WriteDefault creates a default config.yaml if one doesn't exist.

Types

type AnthropicConfig

type AnthropicConfig struct {
	// API key (falls back to ANTHROPIC_API_KEY env var)
	APIKey  string `yaml:"api_key"`
	Model   string `yaml:"model"`
	BaseURL string `yaml:"base_url"`

	// Inference parameters (nil = use provider default)
	Temperature *float64 `yaml:"temperature,omitempty"`
	TopP        *float64 `yaml:"top_p,omitempty"`
	MaxTokens   int      `yaml:"max_tokens,omitempty"`
}

type AuditConfig

type AuditConfig struct {
	// RetentionDays is how long audit rows stay in the database. Zero
	// disables retention entirely. Non-zero is validated to
	// AuditRetentionDaysLower..AuditRetentionDaysUpper.
	RetentionDays int `yaml:"retention_days,omitempty"`

	// ExportDir is where sealed prefixes are written. Empty selects
	// <workdir>/.cloop/audit-archive. A path on separate storage is the
	// better choice for a real deployment: a seal's value comes from being
	// somewhere an attacker who owns the database does not also own.
	ExportDir string `yaml:"export_dir,omitempty"`

	// PruneOnMaintain lets `cloop db maintain` apply the retention window
	// before it vacuums, so the freed pages are reclaimed in the same pass.
	// Without it, retention is only applied by an explicit
	// `cloop hub audit prune`.
	PruneOnMaintain bool `yaml:"prune_on_maintain,omitempty"`
}

AuditConfig is the retention policy for .cloop/state.db's audit_events table (Task 20218).

Retention is opt-in, and deliberately so. The table is the compliance record; a default that quietly started deleting from it would be a worse failure than the unbounded growth it fixes, and an operator who has not chosen a window has not told us which one their regulator allows. Zero — the zero value, and what every existing config already has — keeps everything.

Enabling it does not destroy anything either: a prune seals the removed prefix to ExportDir first and records that file's digest in an anchor, so "retention" here means "moved out of the hot database", not "gone".

type BackgroundConfig

type BackgroundConfig struct {
	// Disabled turns detection off entirely, restoring the older behaviour of
	// trusting a harness that claims success while its work is still running.
	Disabled bool `yaml:"disabled,omitempty"`
	// GraceSeconds is how long a surviving process may live before it counts
	// as background work rather than ordinary teardown. 0 selects the default.
	GraceSeconds int `yaml:"grace_seconds,omitempty"`
	// WaitMinutes bounds how long cloop blocks for that work to finish.
	// 0 selects the default; negative means report it without waiting.
	WaitMinutes int `yaml:"wait_minutes,omitempty"`
	// KeepOrphans leaves work that outlived the budget running instead of
	// terminating it. The task is reported incomplete either way. Off by
	// default: an orphan belongs to no task, and a retry would start a second
	// copy racing the first over the same output files.
	KeepOrphans bool `yaml:"keep_orphans,omitempty"`
}

BackgroundConfig tunes the handling of work an agent harness leaves running after it reports a task complete.

It exists because an agent that starts a training run in the background and then prints TASK_DONE hands the next task an artifact that is still being written. cloop waits for that work and refuses to accept the task while it is unfinished; these knobs adjust how long it waits and what it does with work that never finishes.

func (*BackgroundConfig) Clamp

func (b *BackgroundConfig) Clamp() []string

Clamp bounds the background settings, returning a note for anything it had to correct so a bad config is reported rather than silently reinterpreted.

type BackupConfig

type BackupConfig struct {
	// AutoBackup, when true, instructs the cloop ui server to run a daily
	// backup of .cloop/state.db. Off by default.
	AutoBackup bool `yaml:"auto_backup,omitempty"`

	// Dir is the destination directory for automatic backups. When empty,
	// "<workdir>/.cloop/backups" is used. May be absolute or relative;
	// relative paths resolve against the project working directory.
	Dir string `yaml:"dir,omitempty"`

	// IntervalHours is the period between consecutive automatic backups.
	// Zero substitutes 24 (daily). Minimum 1, capped at 168 (weekly) so a
	// hand-edited config can't disable backups without setting AutoBackup
	// to false.
	IntervalHours int `yaml:"interval_hours,omitempty"`

	// KeepCount is the number of automatic backup files to retain in Dir.
	// Older files (matched by the "state-*.db" naming convention) are
	// removed after each successful run. Zero substitutes 7. A value of
	// -1 disables pruning entirely.
	KeepCount int `yaml:"keep_count,omitempty"`
}

BackupConfig configures hot backups of the SQLite state database.

AutoBackup is opt-in: cloop never spawns a backup goroutine without explicit consent because backups consume disk space and can occasionally pause writers briefly during the WAL checkpoint. Operators who want rolling daily snapshots set `backup.auto_backup: true` and (optionally) `backup.dir` to relocate them off the project working directory.

func (BackupConfig) EffectiveIntervalHours

func (b BackupConfig) EffectiveIntervalHours() int

EffectiveIntervalHours returns the configured interval, substituting 24 when zero. Out-of-range values fall back to the default so a corrupt config can't accidentally suppress all automatic backups.

func (BackupConfig) EffectiveKeepCount

func (b BackupConfig) EffectiveKeepCount() int

EffectiveKeepCount returns the configured retention count. -1 means "keep everything"; zero substitutes 7.

type BudgetConfig

type BudgetConfig struct {
	// MonthlyUSD is the maximum allowed API spend per calendar month.
	// 0 means no limit. When exceeded, cloop warns (or blocks) new task runs.
	MonthlyUSD float64 `yaml:"monthly_usd,omitempty"`

	// DailyUSDLimit is the maximum allowed API spend per calendar day (UTC).
	// 0 means no limit. When exceeded, cloop aborts task execution.
	DailyUSDLimit float64 `yaml:"daily_usd_limit,omitempty"`

	// DailyTokenLimit is the maximum total tokens (input + output) allowed per
	// calendar day (UTC). 0 means no limit.
	DailyTokenLimit int `yaml:"daily_token_limit,omitempty"`

	// AlertThresholdPct is the percentage of the daily budget at which cloop
	// fires a desktop/webhook alert. Default 80 (80%).
	AlertThresholdPct int `yaml:"alert_threshold_pct,omitempty"`

	// GlobalUSDPct caps this project's daily USD spend to a percentage of the
	// global daily USD limit defined in ~/.config/cloop/budget.yaml.
	// E.g. 80 means this project may not exceed 80% of the global daily USD cap.
	// 0 means no percentage cap.
	GlobalUSDPct float64 `yaml:"global_usd_pct,omitempty"`

	// GlobalTokenPct caps this project's daily token usage to a percentage of
	// the global daily token limit defined in ~/.config/cloop/budget.yaml.
	// 0 means no percentage cap.
	GlobalTokenPct float64 `yaml:"global_token_pct,omitempty"`

	// BlockExtraUsage, when true (default), prevents cloop from running tasks
	// when doing so would incur Claude Code extra usage (per-token billing
	// beyond the subscription). Set to false to allow extra usage.
	BlockExtraUsage *bool `yaml:"block_extra_usage,omitempty"`
}

BudgetConfig holds spend limit settings.

func (BudgetConfig) ShouldBlockExtraUsage

func (b BudgetConfig) ShouldBlockExtraUsage() bool

ShouldBlockExtraUsage returns true if extra usage should be blocked. Defaults to true when not explicitly set.

type CIConfig added in v0.0.2

type CIConfig struct {
	// Enabled turns CI federation on. Default false: with it off the
	// exchange and proxy routes refuse, and no pipeline can reach this hub
	// whatever rules are stored.
	Enabled bool `yaml:"enabled,omitempty"`

	// Issuer is the pipeline platform's OIDC issuer. Empty uses GitHub
	// Actions (https://token.actions.githubusercontent.com). Any issuer that
	// publishes a standard discovery document and an RS256 or ES256 key set
	// works; the claim names rules match on are GitHub's, so another forge
	// needs rules written with `condition`.
	Issuer string `yaml:"issuer,omitempty"`

	// Audience is the `aud` a pipeline's token must carry. Empty uses
	// "cloop". Set a distinct value per hub if you run more than one: it is
	// not a security boundary on its own — a workflow may request any
	// audience — but it stops a token minted for one hub being accepted by
	// another that trusts the same repositories.
	Audience string `yaml:"audience,omitempty"`

	// ClockSkewSeconds tolerated on the token's exp/iat/nbf. Zero uses 60;
	// values are clamped to 300. These tokens live minutes, so a generous
	// skew is a meaningful extension of their life.
	ClockSkewSeconds int `yaml:"clock_skew_seconds,omitempty"`

	// DefaultModels is the model allowlist applied to a rule that names
	// none. Empty uses DefaultCIModels. It is never "every model": a rule
	// with no models and a hub with no default mints nothing.
	DefaultModels []string `yaml:"default_models,omitempty"`

	// UpstreamBaseURL overrides the Anthropic API endpoint, for a
	// deployment that already fronts it with a gateway. Empty uses
	// https://api.anthropic.com.
	UpstreamBaseURL string `yaml:"upstream_base_url,omitempty"`

	// UpstreamAuthToken relays with an OAuth bearer instead of an API key.
	// Leave empty to use the hub's anthropic.api_key, which is what nearly
	// every deployment wants: one credential, configured once, in the place
	// the rest of cloop already looks for it.
	UpstreamAuthToken string `yaml:"upstream_auth_token,omitempty"`

	// ExchangeKeepRecords bounds the stored exchange log, which is the
	// operator's debugging surface for "my pipeline says 401". Zero uses
	// CIExchangeKeepDefault.
	ExchangeKeepRecords int `yaml:"exchange_keep_records,omitempty"`
}

CIConfig turns a cloop hub into an OIDC relying party for CI/CD pipelines, and into an Anthropic API proxy for the ones it trusts (Task 20278).

With it on, a GitHub Actions job exchanges its OIDC token at POST /api/ci/token for a short-lived session, then points ANTHROPIC_BASE_URL at this hub and runs Claude Code. The hub's Anthropic credential never reaches the runner.

Which pipelines are trusted is *not* configured here: that lives in the database, editable from Settings → CI/CD, because an allowlist is edited far more often than a deployment is reconfigured and because every edit should leave an audit row. What lives here is the deployment's shape.

func (CIConfig) ClockSkew added in v0.0.2

func (c CIConfig) ClockSkew() int

ClockSkew returns the clamped skew tolerance in seconds.

func (CIConfig) ExchangeKeep added in v0.0.2

func (c CIConfig) ExchangeKeep() int

ExchangeKeep returns the clamped exchange-log bound.

func (CIConfig) Models added in v0.0.2

func (c CIConfig) Models() []string

Models returns the effective default model allowlist.

type ClaudeCodeConfig

type ClaudeCodeConfig struct {
	Model string `yaml:"model"`

	// Effort is the default reasoning-effort level passed to the claude CLI
	// as --effort. Valid: low, medium, high, xhigh, max; empty = CLI default.
	// The per-project effort persisted in project state (set via the Web UI,
	// cloop init --effort, or cloop run --effort) takes precedence.
	Effort string `yaml:"effort,omitempty"`

	// MaxWeeklyPct, when > 0, blocks new runs once the global Anthropic weekly
	// (7-day) utilization reported by the OAuth usage API reaches this percent.
	// Example: 50 means "stop running this project once 50% of the weekly limit
	// has been consumed across all your Claude usage" — useful to reserve
	// headroom for other work.
	MaxWeeklyPct float64 `yaml:"max_weekly_pct,omitempty"`

	// MaxFiveHourPct, when > 0, blocks new runs once the global 5-hour window
	// utilization reaches this percent.
	MaxFiveHourPct float64 `yaml:"max_five_hour_pct,omitempty"`

	// MaxWeeklyOpusPct, when > 0, blocks new runs once the weekly Opus
	// utilization reaches this percent.
	MaxWeeklyOpusPct float64 `yaml:"max_weekly_opus_pct,omitempty"`

	// MaxWeeklySonnetPct, when > 0, blocks new runs once the weekly Sonnet
	// utilization reaches this percent.
	MaxWeeklySonnetPct float64 `yaml:"max_weekly_sonnet_pct,omitempty"`

	// Background tunes what happens to work the harness leaves running after
	// it exits (Task 20205). The zero value is the recommended behaviour.
	Background BackgroundConfig `yaml:"background,omitempty"`
}

type Config

type Config struct {
	// Default provider: anthropic, openai, ollama, claudecode, mock
	Provider string `yaml:"provider"`

	Anthropic  AnthropicConfig  `yaml:"anthropic"`
	OpenAI     OpenAIConfig     `yaml:"openai"`
	Ollama     OllamaConfig     `yaml:"ollama"`
	ClaudeCode ClaudeCodeConfig `yaml:"claudecode"`
	Mock       MockConfig       `yaml:"mock,omitempty"`
	Webhook    WebhookConfig    `yaml:"webhook,omitempty"`
	GitHub     GitHubConfig     `yaml:"github,omitempty"`
	// Router maps task roles to provider names for heterogeneous multi-agent execution.
	// Example: router.backend = "anthropic", router.frontend = "openai"
	Router RouterConfig `yaml:"router,omitempty"`
	// Hooks defines shell commands to run at task and plan lifecycle events.
	Hooks HooksConfig `yaml:"hooks,omitempty"`

	// MaxParallel sets the default worker pool size for parallel PM mode.
	// 0 means no limit (all ready tasks run concurrently).
	// Overridden by --max-parallel / -j on the command line.
	MaxParallel int `yaml:"max_parallel,omitempty"`

	// StepTimeout is the maximum duration per task step (e.g. "10m", "30m", "0").
	// "0" or empty means no timeout. Overridden by --step-timeout on the command line.
	StepTimeout string `yaml:"step_timeout,omitempty"`

	// Watch configures the file-watch mode for `cloop watch --glob`.
	Watch WatchConfig `yaml:"watch,omitempty"`

	// STT configures speech-to-text for dictated tasks (Task 20238).
	STT STTConfig `yaml:"stt,omitempty"`

	// Notify configures Slack and Discord incoming webhook notifications.
	Notify NotifyConfig `yaml:"notify,omitempty"`

	// Sync configures git-based team plan sharing and merging.
	Sync SyncConfig `yaml:"sync,omitempty"`

	// LogJSON switches all cloop output to newline-delimited JSON (NDJSON).
	// Equivalent to passing --log-json on the command line.
	// Each structured event is emitted as a JSON object with fields:
	//   time, level, event, task_id, message, data
	LogJSON bool `yaml:"log_json,omitempty"`

	// Budget configures monthly spend limits.
	Budget BudgetConfig `yaml:"budget,omitempty"`

	// CalibrationFactor is set by 'cloop task effort-calibrate --apply'.
	// When non-zero and != 1.0, Decompose() multiplies every AI-generated
	// time_estimate_minutes value by this factor before storing the task.
	// This closes the feedback loop between historical actuals and future plans.
	CalibrationFactor float64 `yaml:"calibration_factor,omitempty"`

	// RateLimit configures the per-IP token-bucket rate limiter for HTTP servers
	// (cloop serve and cloop ui). Zero values use built-in defaults.
	RateLimit RateLimitConfig `yaml:"rate_limit,omitempty"`

	// Tracing configures OTLP distributed trace export.
	// When Tracing.Enabled is true and Tracing.Endpoint is set, cloop exports
	// per-call spans to the specified OTLP HTTP endpoint (e.g. Jaeger, Tempo,
	// or an OTel Collector). Disabled by default (zero overhead when off).
	Tracing TracingConfig `yaml:"tracing,omitempty"`

	// Orchestrator configures the PM orchestrator's wall-clock execution
	// budgets. See OrchestratorConfig.TaskTimeoutMinutes for the default
	// per-task timeout applied when neither the task nor the project state
	// has a more specific budget set.
	Orchestrator OrchestratorConfig `yaml:"orchestrator,omitempty"`

	// UI configures the cloop ui Web dashboard server. See UIConfig.
	UI UIConfig `yaml:"ui,omitempty"`

	// Backup configures hot backups of .cloop/state.db (Task 20115).
	// When AutoBackup is true, the long-running cloop ui server runs a
	// daily backup and prunes old files. Disabled by default.
	Backup BackupConfig `yaml:"backup,omitempty"`

	// Executors configures the pluggable execution backends (Task 20156+).
	// Absent means "host process only", which is the zero-configuration
	// single-machine default.
	Executors ExecutorsConfig `yaml:"executors,omitempty"`

	// Sandbox configures the envelope around a project's own
	// .cloop/sandbox.yaml (Task 20177). It is a separate section from
	// Executors because it governs what a *project* may ask for, not what
	// backends the operator runs — and it applies identically to every
	// backend that honours an image override.
	Sandbox SandboxConfig `yaml:"sandbox,omitempty"`

	// Audit configures retention for the hash-chained audit trail
	// (Task 20218). Absent means "keep everything", which is what every
	// deployment did before there was a retention path at all.
	Audit AuditConfig `yaml:"audit,omitempty"`

	// Retention configures the background janitor that bounds .cloop
	// (Task 20229). Absent means the defaults in pkg/janitor, which are on:
	// unlike audit retention, the growth this bounds is not a compliance
	// record and an unattended hub that fills its disk is an outage.
	Retention RetentionConfig `yaml:"retention,omitempty"`
}

Config is the project configuration loaded from .cloop/config.yaml.

func Default

func Default() *Config

Default returns a Config with sensible defaults.

func Load

func Load(workdir string) (*Config, error)

Load reads config from .cloop/config.yaml. Returns defaults if missing. Environment variables override file values: ANTHROPIC_API_KEY, OPENAI_API_KEY, ANTHROPIC_BASE_URL, OPENAI_BASE_URL, OLLAMA_BASE_URL, CLOOP_PROVIDER, GITHUB_TOKEN, CLOOP_OIDC_CLIENT_SECRET. On Unix systems, Load prints a warning when the config file is world-readable (permissions wider than 0600) because it may contain API keys.

When the YAML file is missing, Load transparently falls back to the SQLite mirror written by Save() (see stateDBPath / mirrorToSQLite). The SQLite mirror is a recovery store, not the authoritative one — a present YAML file always wins so manual edits keep the expected semantics.

func (*Config) ValidateNumeric

func (c *Config) ValidateNumeric() error

ValidateNumeric returns a non-nil error describing the first numeric range violation found in c, if any. Unlike validateAndClamp it does not mutate; callers (`cloop config set`, the Web UI options endpoints) use it to reject invalid inputs up front rather than silently clamping them. The returned error message is suitable for showing to a user.

type ContainerEgressFilterConfig

type ContainerEgressFilterConfig struct {
	// Enabled turns the filter on. Default false, deliberately: switching it
	// on under a running deployment is a change an operator makes, not one
	// an upgrade makes for them — the symptom would be a sandbox that can no
	// longer reach anything, which reads as a network outage.
	Enabled bool `yaml:"enabled,omitempty"`

	// Internal routes sandboxes onto a network with no route off the host.
	Internal bool `yaml:"internal,omitempty"`

	// AllowCIDRs are address ranges a sandbox may dial directly. This is the
	// only setting that opens private space, and it opens exactly what it
	// names: listing 10.8.0.0/24 does not lift the block on the rest of
	// 10.0.0.0/8, and no entry may cover the cloud metadata service without
	// naming its address outright.
	AllowCIDRs []string `yaml:"allow_cidrs,omitempty"`

	// AllowPorts bounds every allowed destination. Required whenever
	// AllowCIDRs or AllowPublicInternet is set: an allow rule with no port
	// restriction is a hole, and defaulting to 80 and 443 would be this file
	// guessing at a security boundary.
	AllowPorts []int `yaml:"allow_ports,omitempty"`

	// AllowPublicInternet opens every address outside the blocked ranges on
	// AllowPorts. It is what a hostname allowlist necessarily becomes at
	// layer 3, and it is spelled out here rather than inferred so that
	// widening a sandbox to the whole Internet is written down.
	AllowPublicInternet bool `yaml:"allow_public_internet,omitempty"`

	// Resolvers are DNS servers a sandbox may query directly, as address
	// literals ("10.7.0.10" or "10.7.0.10:53"). Without one, a direct-egress
	// filter drops UDP/53 and every name lookup fails — which is the single
	// most common way this section gets misconfigured.
	Resolvers []string `yaml:"resolvers,omitempty"`

	// Broker is the egress proxy endpoint, as an address:port literal. Only
	// needed alongside direct egress; on an internal network the broker is
	// reachable because it shares the bridge.
	//
	// Names are not accepted. A packet filter matches addresses, so a name
	// here would be resolved once at configuration time and pinned silently,
	// which is the DNS rebinding hazard the broker's own resolve-once
	// discipline exists to prevent.
	Broker string `yaml:"broker,omitempty"`

	// HostPatterns records the L7 allowlist this filter cannot enforce, so
	// that the rendered ruleset and the preflight report can say which
	// allowlist was widened into "the public Internet".
	HostPatterns []string `yaml:"host_patterns,omitempty"`
}

ContainerEgressFilterConfig turns a sandbox's outbound access into an enforced allowlist rather than an advisory one.

The distinction is the whole point. The egress broker (executors.egress / `cloop egress grant`) is an HTTP proxy: it enforces hostnames, methods and byte quotas, and it only sees traffic a workload chose to send through $HTTP_PROXY. A harness that opens a raw socket walks past all of it. This section is what binds that harness.

The recommended shape is two lines:

egress_filter:
  enabled: true
  internal: true

which puts sandboxes on a runtime network with no route off the host. The broker then becomes the only way out, which is what makes its host allowlist meaningful — and it needs no host privileges. Everything else here describes *direct* egress, needs nft(8) and CAP_NET_ADMIN, and is for destinations a sandbox must dial itself: a Kubernetes API server, an internal registry.

type ContainerExecutorConfig

type ContainerExecutorConfig struct {
	// Enabled registers the container executor at startup. Off by default:
	// a control plane should not gain a new way to execute code because a
	// container runtime appeared on the host.
	Enabled bool `yaml:"enabled,omitempty"`

	// ID is the executor's registry identifier, used by `cloop executor
	// test <id>` and by project bindings. Empty means "container".
	ID string `yaml:"id,omitempty"`

	// Runtime pins the container runtime: "podman" or "docker". Empty
	// auto-detects, preferring podman (rootless podman confines a workload
	// with a user namespace as well as with flags).
	Runtime string `yaml:"runtime,omitempty"`

	// OCIRuntime pins the low-level runtime that Runtime delegates to, as a
	// name that runtime already knows: "kata", "kata-qemu", "crun". Empty
	// leaves the CLI's default (runc or crun).
	//
	// A Kata name here is how a *local* Kata sandbox is configured: each
	// workload boots in a lightweight VM with its own kernel, so a kernel
	// exploit reaches the guest rather than the host. The executor then
	// reports isolation "vm" and is eligible for projects that require a
	// virtualized sandbox.
	//
	// It must be a registered runtime name and never a path — docker resolves
	// it against /etc/docker/daemon.json and podman against containers.conf,
	// both root-owned. Kata also needs /dev/kvm on the host, which `cloop
	// executor test <id>` checks.
	OCIRuntime string `yaml:"oci_runtime,omitempty"`

	// Image is the sandbox image reference. Empty uses the driver's
	// documented default; see container.DefaultImage for the contract an
	// image must satisfy.
	Image string `yaml:"image,omitempty"`

	// CPUs is the default core allowance per workload (1.5 = one and a half
	// cores). Zero means unlimited. Bounded by ContainerCPUsUpper.
	CPUs float64 `yaml:"cpus,omitempty"`

	// Memory is the default memory ceiling per workload, as a size string:
	// "512m", "2g", "1024k". A bare integer is read as megabytes. Empty
	// means unlimited. Swap is pinned to the same value, so a workload
	// cannot page past its ceiling.
	Memory string `yaml:"memory,omitempty"`

	// PIDsLimit caps processes and threads per workload. Zero uses the
	// driver default (1024); -1 disables the cap.
	PIDsLimit int `yaml:"pids_limit,omitempty"`

	// Network is "none" (default), "bridge", or an operator-defined network
	// name. "host" is rejected: it removes network isolation entirely and
	// exposes services bound to the host loopback, including the control
	// plane's own API.
	//
	// cloop does not filter egress itself. Anything other than "none" grants
	// unrestricted outbound access unless the named network carries its own
	// policy (a podman --internal network, a CNI plugin, an egress proxy).
	Network string `yaml:"network,omitempty"`

	// AllowHosts pins name resolution inside the sandbox as "host:address"
	// entries. Only valid when Network is not "none".
	AllowHosts []string `yaml:"allow_hosts,omitempty"`

	// ExtraArgs are additional runtime flags. Each must be a flag in
	// --flag=value form, and flags that would dismantle the sandbox
	// (--privileged, --cap-add, --volume, --network, ...) are rejected.
	ExtraArgs []string `yaml:"extra_args,omitempty"`

	// SELinuxLabel is the relabel option applied to bind mounts on SELinux
	// hosts: "z" (shared) or "Z" (private). Required for the workspace to be
	// readable inside the container when SELinux is enforcing.
	SELinuxLabel string `yaml:"selinux_label,omitempty"`

	// OrphanGracePeriodSeconds protects a young container from the startup
	// reconcile sweep. Zero means 600, the same default and meaning as the
	// Kubernetes executor's field of the same name (Task 20191).
	//
	// It is what makes reaping a *running* orphan safe: a container that
	// appeared moments ago may belong to a control plane starting alongside
	// this one, or to this one between `run -d` and recording the handle.
	OrphanGracePeriodSeconds int64 `yaml:"orphan_grace_period_seconds,omitempty"`

	// AllowRootUser permits the sandbox to run as uid 0. Default false.
	//
	// The workload's UID is derived from the project directory's owner, so a
	// control plane running as root over a root-owned project would otherwise
	// get a root sandbox without anyone having chosen one. Root in a container
	// defeats --cap-drop=ALL and turns a runtime escape into host root, so
	// that configuration is refused unless this is set deliberately. The
	// better fix is almost always to chown the project directory to an
	// unprivileged user.
	AllowRootUser bool `yaml:"allow_root_user,omitempty"`

	// EgressFilter bounds what a sandbox on this executor can reach, at the
	// IP layer. Disabled means the configured Network is used as-is, with
	// whatever egress it happens to provide.
	EgressFilter ContainerEgressFilterConfig `yaml:"egress_filter,omitempty"`
}

ContainerExecutorConfig configures the container sandbox executor (pkg/executor/container).

Every field here narrows what a workload can do, so a malformed value must never widen it. That is why parsing is strict — an unparsable memory limit is an error, not a silently-dropped limit — while the *absence* of a value falls back to the driver's own conservative defaults (no network, all capabilities dropped, a process cap).

func (ContainerExecutorConfig) DriverOptions

func (c ContainerExecutorConfig) DriverOptions() (container.Options, error)

DriverOptions converts the config section into driver options, applying the driver's own normalisation and validation.

It is the single conversion point: the CLI, the UI, and the config validator all go through it, so none of them can construct an executor whose confinement differs from what the config describes.

type CosignIdentity

type CosignIdentity struct {
	// Issuer is the exact OIDC issuer URL that minted the signing
	// certificate, e.g. https://token.actions.githubusercontent.com.
	Issuer string `yaml:"issuer"`
	// SubjectRegexp constrains which subject at that issuer is trusted.
	// Required: an issuer alone would trust every workflow at that provider,
	// including one in a repository the operator has never heard of.
	SubjectRegexp string `yaml:"subject_regexp"`
}

CosignIdentity is one keyless-signing identity in the config file.

type EgressConfig

type EgressConfig struct {
	// Enabled starts the proxy with the control plane. Off by default: a
	// control plane should not acquire a new outbound path because a config
	// file grew a section.
	Enabled bool `yaml:"enabled,omitempty"`

	// ListenAddr is the proxy's bind address. Empty binds an ephemeral
	// loopback port.
	//
	// Loopback is the safe default and frequently an unusable one: a
	// container on a bridge network cannot reach the host's 127.0.0.1, so an
	// operator running sandboxes sets an address the sandbox can route to —
	// at which point the proxy is reachable by everything else on that
	// network, and the per-session credential is what holds. Bind as
	// narrowly as the sandbox network allows.
	ListenAddr string `yaml:"listen_addr,omitempty"`

	// AdvertiseAddr is what goes into the sandbox's HTTPS_PROXY. Empty uses
	// the bound address.
	//
	// It exists because the two are routinely different: podman reaches the
	// host at host.containers.internal, docker at host.docker.internal, and
	// a remote edge executor at whatever address the control plane has on
	// the network between them. Getting it wrong produces a sandbox whose
	// every outbound request times out, which is a slow thing to diagnose.
	AdvertiseAddr string `yaml:"advertise_addr,omitempty"`

	// MaxSessionMinutes bounds one redemption regardless of what a grant
	// asks for. Zero uses the broker's 15-minute default; values outside
	// [1, EgressMaxSessionMinutesUpper] are clamped.
	MaxSessionMinutes int `yaml:"max_session_minutes,omitempty"`

	// DialTimeoutSeconds bounds the connection to an authorised origin.
	// Zero uses the broker's default.
	DialTimeoutSeconds int `yaml:"dial_timeout_seconds,omitempty"`

	// DefaultMaxBytesUp and DefaultMaxBytesDown are the per-session transfer
	// quotas applied to a grant created without explicit ones, as size
	// strings ("100m", "2g"). Empty means unlimited.
	DefaultMaxBytesUp   string `yaml:"default_max_bytes_up,omitempty"`
	DefaultMaxBytesDown string `yaml:"default_max_bytes_down,omitempty"`
}

EgressConfig configures the egress broker's forward proxy.

The proxy is what makes network-isolated execution usable rather than merely safe. A container started with network "none" cannot fetch a dependency, and the usual alternative is to give the sandbox a real network and hope; with this enabled it reaches exactly the hosts an egress grant names, brokered per connection by the control plane.

type ExecutorLimitsConfig added in v0.0.2

type ExecutorLimitsConfig struct {
	// MaxCPU caps the core allowance of one workload (2 = two cores).
	MaxCPU float64 `yaml:"max_cpu,omitempty"`
	// MaxMemory caps resident memory, as a size string ("2g", "512m").
	MaxMemory string `yaml:"max_memory,omitempty"`
	// MaxDisk caps the workspace and scratch space, as a size string.
	MaxDisk string `yaml:"max_disk,omitempty"`
	// MaxPIDs caps processes/threads in one workload.
	MaxPIDs int `yaml:"max_pids,omitempty"`
}

ExecutorLimitsConfig is the hub-wide ceiling on what any single workload may be given, whichever executor runs it.

The fields are strings and a float for the same reason the rest of the executor config is: an operator writes "8g", not 8192, and a config file that demands megabytes invites the off-by-1024 error that makes a sandbox OOM on startup. Parsing happens here, once, with the same grammar every other size in this file uses.

Absent or zero on any field means that resource is uncapped — the behaviour every deployment had before this section existed, which is what keeps adding it a no-op upgrade rather than a surprise outage.

func (ExecutorLimitsConfig) Ceiling added in v0.0.2

Ceiling converts the configured section to the executor package's ceiling type, returning the first parse error.

Errors rather than clamps, unlike most of this file. Load() clamps an out-of-range value and warns, which is right when the setting is a preference: the hub boots, slightly different from what was asked for. A ceiling that silently became a different number is a policy the operator believes they have and does not, so the malformed case is surfaced instead — through ValidateExecutors at `config set`, and through ExecutorLimitWarnings for a hand-edited file, which is the same two-path treatment executors.min_agent_build gets and for the same reason.

func (ExecutorLimitsConfig) IsZero added in v0.0.2

func (l ExecutorLimitsConfig) IsZero() bool

IsZero reports whether the section caps nothing.

type ExecutorsConfig

type ExecutorsConfig struct {
	// AllowHostProcess permits the localprocess driver — running agent
	// workloads as child processes of the control plane, with its user, its
	// filesystem, and its network. Absent means true, so an existing
	// single-machine install keeps working across an upgrade.
	//
	// This is the one setting an enterprise deployment must flip. With it
	// false, cloop enters strict no-host-execution mode: the localprocess
	// driver refuses to Start, executor.Resolve refuses to hand it out, and
	// every Web UI path that would have spawned a harness returns 409 naming
	// the configured alternatives. That is what turns "the web UI never
	// directly spawns a harness on the host" from a convention into an
	// enforced invariant.
	//
	// It is a *bool rather than a bool because all three states are
	// distinguishable and matter: absent (legacy default, permissive),
	// explicitly true (an operator decided), explicitly false (hardened). A
	// plain bool with omitempty would drop an explicit false on the next
	// Save, silently re-opening host execution.
	AllowHostProcess *bool `yaml:"allow_host_process,omitempty"`

	// MinAgentBuild is the oldest cloop build a remote executor agent may be
	// running and still receive work. Empty — the default — means no floor.
	//
	// It exists because the negotiated protocol version cannot express most of
	// what changes between builds. The protocol number moves only when a frame
	// moves, so two agents can both speak the current protocol and differ by a
	// year of fixes to things the wire never sees. An operator who has deployed
	// such a fix had no way to stop scheduling onto the devices that predate
	// it, and learned which ones those were from the failures.
	//
	// A device that cannot prove it meets the floor — one reporting no build,
	// the legacy "1" placeholder, or an unreleased build that carries no
	// comparable version — is refused along with one that is genuinely older,
	// and the placement error says which of the two it was. Setting a floor is
	// a request for devices that can substantiate their build, not for devices
	// that decline to answer.
	//
	// Applied as a ratchet (executor.ApplyMinAgentBuild): a hub reads many
	// projects' config.yaml, and a tenant-controlled file must not be able to
	// lower a fleet-wide floor. Lowering means restarting with the looser
	// config. See pkg/executor/agentbuild.go.
	MinAgentBuild string `yaml:"min_agent_build,omitempty"`

	// OrphanSweepIntervalMinutes is how often the hub re-runs the orphan sweep
	// that collects workloads it is no longer tracking. Absent uses
	// reconcile.DefaultSweepInterval (15 minutes); an explicit 0 disables it.
	//
	// Before Task 20281 the sweep ran only at process start, which covered the
	// case it was written for — a control plane killed mid-run — and no other.
	// A node eviction, a cluster upgrade that drains a node, or an operator
	// deleting a Pod by hand all orphan objects while the hub is perfectly
	// healthy, and those accumulated until someone restarted it.
	//
	// A *int for the reason AllowHostProcess is a *bool: absent, explicitly
	// zero and explicitly set are three different intentions, and a plain int
	// with omitempty would drop an operator's deliberate 0 on the next Save,
	// silently re-enabling a sweep they had turned off.
	OrphanSweepIntervalMinutes *int `yaml:"orphan_sweep_interval_minutes,omitempty"`

	// Limits is the hub-wide ceiling on what any single workload may be given,
	// whichever executor runs it.
	//
	// It is the only resource setting in this file that is a *bound* rather
	// than a default. The per-driver keys below (container.memory, and the
	// kubernetes requests/limits) say what a workload gets when it asks for
	// nothing, and a project's .cloop/sandbox.yaml overrides them — which is
	// correct for a default and useless as a policy, because that file is
	// committed to the repository and authored by whoever can push to it.
	// These keys cannot be overridden by it. See pkg/executor/ceiling.go.
	//
	// Applied as a ratchet (executor.ApplyResourceCeiling) for the reason
	// MinAgentBuild is: a control plane reads many projects' config.yaml, and
	// applying them symmetrically would let one tenant's file raise a
	// fleet-wide cap for every other tenant in the process.
	Limits ExecutorLimitsConfig `yaml:"limits,omitempty"`

	// Container configures the Docker/Podman sandbox executor.
	Container ContainerExecutorConfig `yaml:"container,omitempty"`

	// Kubernetes configures the ephemeral-Pod executor.
	Kubernetes KubernetesExecutorConfig `yaml:"kubernetes,omitempty"`

	// Egress configures the scoped forward proxy that lends the control
	// plane's Internet connection to isolated sandboxes.
	Egress EgressConfig `yaml:"egress,omitempty"`

	// GitProxy configures the git interception proxy that keeps the forge
	// credential on the hub and enforces a branch allowlist on every push.
	GitProxy GitProxyConfig `yaml:"git_proxy,omitempty"`

	// KubeGuard configures the Kubernetes access monitor that keeps the
	// cluster credential on the hub and decides every API request.
	KubeGuard KubeGuardConfig `yaml:"kube_guard,omitempty"`
}

ExecutorsConfig groups the execution backends a control plane offers.

Each sub-struct configures one driver. A driver is registered only when its section is present and enabled, so adding a backend is an explicit operator decision rather than something that materialises because a binary happened to be on PATH.

func (ExecutorsConfig) HostProcessAllowed

func (e ExecutorsConfig) HostProcessAllowed() bool

HostProcessAllowed reports the effective policy, applying the permissive-by-default rule for an absent setting.

func (ExecutorsConfig) HostProcessExplicit

func (e ExecutorsConfig) HostProcessExplicit() bool

HostProcessExplicit reports whether an operator stated the policy, as opposed to inheriting the back-compat default. The UI banner uses it to distinguish "permissive because nobody has decided yet" from "permissive on purpose".

func (ExecutorsConfig) OrphanSweepInterval added in v0.0.2

func (e ExecutorsConfig) OrphanSweepInterval() time.Duration

OrphanSweepInterval renders the configured cadence as a duration for reconcile.Options.SweepInterval.

The two encodings differ on purpose, and this function is the single place that reconciles them. An operator writes 0 to mean "off", because that is what 0 means in a config file. reconcile.Options is a Go struct whose zero value has to be the production default, so "off" there is negative. Doing the translation here keeps every caller from having to remember which convention it is holding — and getting that backwards would silently disable the sweep on every deployment that never set the field.

A negative value in the file is read as "off" too: it is not a cadence, and refusing to run is the safer reading of a value nobody can have meant.

func (*ExecutorsConfig) SetHostProcessAllowed

func (e *ExecutorsConfig) SetHostProcessAllowed(allowed bool)

SetHostProcessAllowed records an explicit policy decision.

type GitHubConfig

type GitHubConfig struct {
	// Personal access token (falls back to GITHUB_TOKEN env var)
	Token string `yaml:"token,omitempty"`
	// Default repo in "owner/repo" format (falls back to git remote detection)
	Repo string `yaml:"repo,omitempty"`
	// Labels added to issues created by cloop push
	Labels []string `yaml:"labels,omitempty"`
}

GitHubConfig holds GitHub integration settings.

type GitProxyConfig

type GitProxyConfig struct {
	// Enabled starts the proxy with the control plane and routes every git
	// workspace through it. Off by default: interposing a proxy changes the
	// URL sandboxes push to, and that must be an operator's decision rather
	// than something a config file acquires on upgrade.
	Enabled bool `yaml:"enabled,omitempty"`

	// ListenAddr is the proxy's bind address. Empty binds an ephemeral
	// loopback port, which is safe and — for anything but a sandbox sharing
	// the host's network namespace — unusable. See AdvertiseURL.
	ListenAddr string `yaml:"listen_addr,omitempty"`

	// AdvertiseURL is the https base URL sandboxes are pointed at. It must be
	// https, carry no path, and be reachable *from the sandbox*, which is
	// frequently not where the hub sees itself: podman reaches the host at
	// host.containers.internal, docker at host.docker.internal, a Pod at a
	// Service name, and an edge device at whatever address the hub has on the
	// link between them. Empty falls back to the bound address, which is
	// correct only when the sandbox shares the host's network namespace.
	AdvertiseURL string `yaml:"advertise_url,omitempty"`

	// CertFile and KeyFile are the proxy's TLS material. Both are required
	// when Enabled: the session token rides an Authorization header on every
	// request, and over cleartext that token is published rather than
	// delivered. A loopback listener is not an exception — a sandbox is by
	// construction something that may share a host with other listeners.
	//
	// The certificate must be trusted by the *sandbox's* git, not by the
	// hub's. A self-signed hub certificate needs its CA in the sandbox image,
	// or git refuses the connection.
	CertFile string `yaml:"cert_file,omitempty"`
	KeyFile  string `yaml:"key_file,omitempty"`

	// MinTLSVersion is "1.2" or "1.3". Empty means 1.2, matching the hub.
	MinTLSVersion string `yaml:"min_tls_version,omitempty"`

	// SessionMinutes bounds one session: the window covering a run's clone at
	// the start and its write-back push at the end. Zero uses 60. Values
	// outside [1, GitProxySessionMinutesUpper] are clamped.
	//
	// This is the deadline that actually bounds a sandbox's access to the
	// forge. Before interception the sandbox held the PAT and its access
	// ended never; a run that outlives this fails its push, which is by far
	// the better of the two failures.
	SessionMinutes int `yaml:"session_minutes,omitempty"`

	// AllowedRefs overrides the branch allowlist. Empty means
	// refs/heads/cloop/** — the write-back namespace, and the only thing an
	// executor needs.
	//
	// Widening this is how a hub stops protecting the branches a human owns,
	// so it deliberately has no convenient shorthand: a pattern is a full ref
	// glob, and "*" does not cross a "/".
	AllowedRefs []string `yaml:"allowed_refs,omitempty"`

	// AllowDelete permits a sandbox to delete refs it may otherwise write.
	// Off by default, and rarely right: a write-back never deletes, and an
	// allowlist that forgot about deletes would let a sandbox destroy the
	// very branches it was scoped to.
	AllowDelete bool `yaml:"allow_delete,omitempty"`
}

GitProxyConfig configures the git interception proxy.

With it off, a sandbox that pushes a work product is handed a GitHub PAT and asked to confine itself to the cloop/ namespace; that check runs in code the sandbox itself executes, so it is a convention. With it on, the PAT stays on the hub, the sandbox gets a session token worth nothing anywhere else, and the branch allowlist is enforced on the push's own ref-update list by a process the sandbox does not control.

See docs/git-interception-proxy.md.

func (GitProxyConfig) Policy

func (g GitProxyConfig) Policy() gitproxy.Policy

Policy renders the configured allowlist as the policy the proxy enforces.

Fetch is always permitted: with a proxy interposed the provisioning clone goes through it too, and that is the point — it is what keeps the forge credential off the sandbox for both halves of the round trip, not just the push.

func (GitProxyConfig) SessionTTLMinutes

func (g GitProxyConfig) SessionTTLMinutes() int

SessionTTLMinutes returns the effective session length, applying the default for an unset value. It does not clamp: an out-of-range value is repaired by clampGitProxyConfig at load, and silently narrowing here would hide it.

type HooksConfig

type HooksConfig struct {
	// PreTask runs before each task. Exit non-zero causes the task to be skipped.
	// Env: CLOOP_TASK_ID, CLOOP_TASK_TITLE, CLOOP_TASK_STATUS, CLOOP_TASK_ROLE
	PreTask string `yaml:"pre_task,omitempty"`
	// PostTask runs after each task completes (regardless of outcome).
	// Same env vars as PreTask, with CLOOP_TASK_STATUS set to the final status.
	PostTask string `yaml:"post_task,omitempty"`
	// PrePlan runs once before plan execution begins.
	// Env: CLOOP_PLAN_GOAL, CLOOP_PLAN_TOTAL
	PrePlan string `yaml:"pre_plan,omitempty"`
	// PostPlan runs once after the plan finishes.
	// Env: CLOOP_PLAN_GOAL, CLOOP_PLAN_TOTAL, CLOOP_PLAN_DONE, CLOOP_PLAN_FAILED, CLOOP_PLAN_SKIPPED
	PostPlan string `yaml:"post_plan,omitempty"`
	// PostTaskReview enables AI code review annotations after each successful task.
	// Equivalent to passing --post-review on the command line.
	PostTaskReview bool `yaml:"post_task_review,omitempty"`
	// Timeout caps the wall-clock duration of every hook invocation, parsed via
	// time.ParseDuration (e.g. "30s", "5m", "2h"). Empty defaults to 10 minutes.
	// Use "-1s" (or any negative duration) to disable the timeout for hooks
	// that legitimately exceed the default.
	Timeout string `yaml:"timeout,omitempty"`
}

HooksConfig holds shell commands executed at task and plan lifecycle events. Commands run via "sh -c" with context passed as environment variables.

type ImagePolicyConfig

type ImagePolicyConfig struct {
	// AllowedRegistries are the registry hosts a project may name. Matching
	// is on the parsed host: "docker.io" does not match "docker.io.evil.com"
	// or "evil.com/docker.io/foo". "*.example.com" matches subdomains at a
	// label boundary; "*" matches any registry.
	AllowedRegistries []string `yaml:"allowed_registries,omitempty"`
	// AllowedRepos narrows further, e.g. "ghcr.io/acme/*". Empty means any
	// repository on an allowed registry.
	AllowedRepos []string `yaml:"allowed_repos,omitempty"`
	// RequireDigest refuses a reference pinned only to a tag.
	RequireDigest bool `yaml:"require_digest,omitempty"`
	// RequireSignature refuses an image cosign cannot verify. A hub with no
	// cosign installed then refuses every image rather than admitting them
	// unchecked — that is the intended behaviour, not a bug.
	RequireSignature bool `yaml:"require_signature,omitempty"`
	// CosignPublicKeys are paths to public keys; any one may carry the
	// signature.
	CosignPublicKeys []string `yaml:"cosign_public_keys,omitempty"`
	// CosignIdentities are keyless identities; any one may carry it.
	CosignIdentities []CosignIdentity `yaml:"cosign_identities,omitempty"`
	// DefaultRegistry qualifies a reference that names no registry. Without
	// it an unqualified name like "alpine:3" is refused, because which
	// registry it resolves to is the executor host's configuration rather
	// than anything this policy can see.
	DefaultRegistry string `yaml:"default_registry,omitempty"`
}

ImagePolicyConfig is the YAML shape of imagepolicy.Policy.

It is a separate struct from the policy type rather than a yaml-tagged reuse of it, for the same reason ContainerExecutorConfig is separate from container.Options: the file format is a compatibility surface an operator depends on, and the policy type is free to change shape without breaking it.

func (ImagePolicyConfig) Policy

Policy converts the config section into the enforcement type.

It is the single conversion point, so the CLI validator, the hub's drivers and the sandbox parser cannot end up applying three different readings of the same file.

type KubeGuardConfig added in v0.0.2

type KubeGuardConfig struct {
	// Enabled starts the monitor with the control plane and routes every
	// kubeconfig grant through it. Off by default: interposing a monitor
	// changes the server a sandbox's kubectl talks to, and that must be an
	// operator's decision rather than something a config file acquires on
	// upgrade.
	Enabled bool `yaml:"enabled,omitempty"`

	// ListenAddr is the monitor's bind address. Empty binds an ephemeral
	// loopback port, which is safe and — for anything but a sandbox sharing
	// the host's network namespace — unusable. See AdvertiseURL.
	ListenAddr string `yaml:"listen_addr,omitempty"`

	// AdvertiseURL is the https base URL sandboxes are pointed at; it becomes
	// the `server:` field of the delivered kubeconfig. It must be reachable
	// *from the sandbox*, which is frequently not where the hub sees itself:
	// podman reaches the host at host.containers.internal, docker at
	// host.docker.internal, a Pod at a Service name, and an edge device at
	// whatever address the hub has on the link between them.
	AdvertiseURL string `yaml:"advertise_url,omitempty"`

	// CertFile and KeyFile are the monitor's TLS material. Both are required
	// when Enabled: the session token rides an Authorization header on every
	// request, and over cleartext that token is published rather than
	// delivered.
	CertFile string `yaml:"cert_file,omitempty"`
	KeyFile  string `yaml:"key_file,omitempty"`

	// CAFile is the PEM bundle embedded into the delivered kubeconfig as
	// certificate-authority-data, so the sandbox's kubectl trusts the
	// monitor. Empty falls back to CertFile, which is the right answer for a
	// self-signed certificate and harmless for a publicly-signed one.
	//
	// This exists because a kubeconfig can carry its own trust anchor, unlike
	// git, which has to be told through the image's CA store. It is the one
	// place this section is *easier* to deploy than executors.git_proxy.
	CAFile string `yaml:"ca_file,omitempty"`

	// MinTLSVersion is "1.2" or "1.3". Empty means 1.2, matching the hub.
	MinTLSVersion string `yaml:"min_tls_version,omitempty"`

	// SessionMinutes bounds one session. Zero uses 60. Values outside
	// [1, KubeGuardSessionMinutesUpper] are clamped.
	//
	// This is the deadline that actually bounds a sandbox's access to the
	// cluster. Before the monitor the sandbox held the kubeconfig and its
	// access ended when someone rotated the credential.
	SessionMinutes int `yaml:"session_minutes,omitempty"`

	// Verbs, Namespaces and Resources are a deployment-wide *floor*, not the
	// policy a session runs under. Each grant carries its own, and the
	// session gets the intersection — so narrowing these narrows every
	// project on the hub, and leaving them empty lets each grant speak for
	// itself.
	//
	// Empty means *no ceiling*: each grant decides, and a grant that names no
	// verbs of its own is read-only. Setting these to the read set explicitly
	// is how an operator says "no project on this hub may ever write to a
	// cluster", regardless of what any grant asks for.
	//
	// The difference between unset and read-only is load-bearing, which is
	// why Policy() does not normalise these: defaulting an unset floor to the
	// read set would make `cloop secret grant --verbs create` silently
	// produce a read-only session with no error saying why.
	Verbs      []string `yaml:"verbs,omitempty"`
	Namespaces []string `yaml:"namespaces,omitempty"`
	Resources  []string `yaml:"resources,omitempty"`
}

KubeGuardConfig configures the Kubernetes access monitor (Task 20277).

With it off, a kubeconfig grant delivers a *minimised* kubeconfig: the contexts outside the allowlist are gone, and with them the clusters and users nothing references. That is real, and for "which clusters may this project reach" it is sufficient. It cannot do the other two things:

  • the namespace in a delivered context is a client-side default, so `kubectl -n kube-system get secrets` ignores it entirely;
  • read-only cannot be expressed in a kubeconfig at all, because the document carries a credential and the credential's authority is whatever the cluster's RBAC says — frequently cluster-admin, since the kubeconfig a developer uploads is usually their own.

With it on, the cluster credential stays on the hub. The sandbox receives a kubeconfig pointing at this monitor, holding a bearer token worth only what the grant's verbs and namespaces permit, for the session's TTL. Every request is parsed and decided before it reaches the API server, and every refusal lands in the audit trail.

func (KubeGuardConfig) Policy added in v0.0.2

func (k KubeGuardConfig) Policy() kubeguard.Policy

Policy renders the section's deployment-wide policy floor.

This is a *floor*, not the policy a session runs under. Each grant carries its own verbs and namespaces, and the session policy is the intersection — see kubeguard.Policy.Intersect and the wiring in pkg/ui/kubeguard.go. An operator who narrows this narrows every project on the hub; one who leaves it empty gets read-only, which is what a grant that says nothing gets too.

func (KubeGuardConfig) SessionTTLMinutes added in v0.0.2

func (k KubeGuardConfig) SessionTTLMinutes() int

SessionTTLMinutes returns the effective session length, applying the default for an unset value. It does not clamp: an out-of-range value is repaired at load, and silently narrowing here would hide it.

type KubernetesEgressFilterConfig

type KubernetesEgressFilterConfig struct {
	// Enabled creates a NetworkPolicy alongside every Pod. Off means no policy
	// object at all, not an empty one — an empty one denies everything.
	Enabled bool `yaml:"enabled,omitempty"`

	// CIDRs are the destination ranges a workload may reach ("10.8.0.0/24").
	//
	// They are also the only thing that waives the hard-block set — loopback,
	// RFC1918, link-local, CGNAT, multicast and the cloud metadata endpoint —
	// exactly as in the egress proxy: naming a private range explicitly buys
	// that range and nothing else. A /0 is refused; see allow_public_internet,
	// which says the same thing and attaches a warning to it.
	CIDRs []string `yaml:"cidrs,omitempty"`

	// Ports bound every destination allow, as destination port numbers.
	// Naming destinations without ports is an error rather than a guess: an
	// allow rule with no port restriction is a hole, and "they probably meant
	// 443" is not a decision a firewall compiler should make on an operator's
	// behalf.
	Ports []int `yaml:"ports,omitempty"`

	// AllowPublicInternet opens every address outside the block set, on Ports.
	//
	// It is the only way to express a hostname allowlist at layer 3, because
	// hostnames do not exist there — "*.github.com" resolves to addresses a
	// packet filter cannot enumerate. Setting it states that the sandbox may
	// dial the Internet directly; the narrow alternative is to route it through
	// the egress broker, which enforces hosts where they can be enforced.
	AllowPublicInternet bool `yaml:"allow_public_internet,omitempty"`

	// Resolvers are DNS servers the sandbox may query directly, as
	// "10.96.0.10" or "10.96.0.10:53". Needed only when the workload resolves
	// somewhere other than cluster DNS.
	Resolvers []string `yaml:"resolvers,omitempty"`

	// AllowClusterDNS opens UDP and TCP 53 to the kube-system namespace.
	// Unset means true, which is why it is a pointer: a default-deny egress
	// policy without it breaks name resolution, and that failure reads to
	// everyone involved as "the network is broken" rather than "DNS is denied".
	AllowClusterDNS *bool `yaml:"allow_cluster_dns,omitempty"`
}

KubernetesEgressFilterConfig turns a Pod's cluster egress into an enforced allowlist.

It exists because the label was not enough. This executor has always set cloop.dev/egress on its Pods to say whether the workload was supposed to reach the network, and has always admitted that the label enforced nothing: a Pod joins the pod network, and no field in a Pod spec takes that away. Everything a sandbox could reach — other tenants' Pods, the node's kubelet, an internal service that trusts its network position, the Internet — it reached, whatever .cloop/sandbox.yaml asked for.

The section is off by default and that default is load-bearing: an existing deployment upgrading into this code must not have its workloads firewalled by a field nobody set, and a security control that arrives switched on without being asked for is one operators learn to switch off. Enabling it is a decision, made once, in a file.

The allowlist compiles to the same pkg/netfilter policy the container executor's nftables ruleset compiles from, so "allowed" means the same thing on both backends rather than being two rule sets that agree until they do not. What it cannot promise is enforcement: a NetworkPolicy is applied by the cluster's CNI, and `cloop executor test` warns that cloop cannot tell from the API whether yours implements one.

type KubernetesExecutorConfig

type KubernetesExecutorConfig struct {
	// Enabled registers the executor at startup. Off by default.
	Enabled bool `yaml:"enabled,omitempty"`

	// ID is the executor's registry identifier, used by `cloop executor
	// test <id>`, by grants (`--to executor:<id>`) and by project bindings.
	// Empty means "kubernetes".
	ID string `yaml:"id,omitempty"`

	// KubeconfigSecret names the brokered kubeconfig secret, by name or ID.
	// Empty accepts the first kubeconfig grant issued to this executor,
	// which is the common single-cluster case.
	KubeconfigSecret string `yaml:"kubeconfig_secret,omitempty"`

	// InCluster authenticates as the hub Pod's own ServiceAccount instead of
	// leasing a kubeconfig from the broker. Only meaningful when the hub is
	// itself running in the cluster it schedules into, which is what the
	// Helm chart in deploy/helm/cloop-hub sets up.
	//
	// It is not a weaker boundary than a brokered kubeconfig, it is a
	// different one: what the executor may do is a Role in the cluster,
	// enforced by the API server, rather than a grant in cloop's database
	// enforced by cloop. Mutually exclusive with KubeconfigSecret, because
	// "which identity is this executor using" must have one answer.
	InCluster bool `yaml:"in_cluster,omitempty"`

	// Context selects a kubeconfig context. Empty uses current-context.
	// Rarely needed: the broker already minimizes a kubeconfig to the
	// contexts a grant permits, usually leaving exactly one.
	Context string `yaml:"context,omitempty"`

	// Namespace is where Pods are created. Empty falls back to the grant's
	// pinned namespace, then the kubeconfig context's, then "cloop".
	Namespace string `yaml:"namespace,omitempty"`

	// Image is the harness image. Unlike the container executor there is no
	// bind-mounted host binary to fall back on, so this image must contain
	// cloop. Pin it by digest.
	Image string `yaml:"image,omitempty"`

	// ImagePullPolicy is "Always", "IfNotPresent" or "Never". Empty uses the
	// cluster default.
	ImagePullPolicy string `yaml:"image_pull_policy,omitempty"`

	// ImagePullSecrets names Secrets in Namespace holding registry auth.
	ImagePullSecrets []string `yaml:"image_pull_secrets,omitempty"`

	// ServiceAccount runs Pods under a named ServiceAccount. Its token is
	// still never mounted — automountServiceAccountToken is forced false —
	// so this is for image-pull secrets and Pod Security admission, not for
	// granting the workload cluster access.
	ServiceAccount string `yaml:"service_account,omitempty"`

	// CPURequest/CPULimit/MemoryRequest/MemoryLimit are Kubernetes quantity
	// strings: "500m", "2", "512Mi", "4Gi".
	CPURequest    string `yaml:"cpu_request,omitempty"`
	CPULimit      string `yaml:"cpu_limit,omitempty"`
	MemoryRequest string `yaml:"memory_request,omitempty"`
	MemoryLimit   string `yaml:"memory_limit,omitempty"`

	// EphemeralStorageLimit bounds writable scratch and logs ("10Gi").
	EphemeralStorageLimit string `yaml:"ephemeral_storage_limit,omitempty"`
	// WorkspaceSizeLimit bounds the workspace emptyDir specifically.
	WorkspaceSizeLimit string `yaml:"workspace_size_limit,omitempty"`

	// NodeSelector pins scheduling to labelled nodes.
	NodeSelector map[string]string `yaml:"node_selector,omitempty"`

	// Tolerations lets Pods schedule onto tainted nodes — the usual way to
	// reach a dedicated, untrusted-workload node pool.
	Tolerations []kubernetes.Toleration `yaml:"tolerations,omitempty"`

	// RuntimeClass names a RuntimeClass for every Pod, e.g. "kata",
	// "kata-qemu" or "kata-clh". Empty leaves the cluster default (runc).
	//
	// This is how a *remote* Kata sandbox is configured: kube-scheduler places
	// the Pod on a node advertising that handler and the workload boots in a
	// VM with its own kernel, on a machine that is not the control plane's.
	// The executor then reports virtualized: true.
	//
	// Kata node pools are conventionally tainted, so this usually appears
	// alongside tolerations and node_selector. The class must already exist in
	// the cluster; cloop does not create it.
	RuntimeClass string `yaml:"runtime_class,omitempty"`

	// ActiveDeadlineSeconds is the server-side wall-clock ceiling applied
	// when a run requests no timeout of its own. Zero means unbounded,
	// matching cloop's project-wide decision that runs are long-lived.
	// Enforced by the API server, so it survives a control-plane restart.
	ActiveDeadlineSeconds int64 `yaml:"active_deadline_seconds,omitempty"`

	// TerminationGracePeriodSeconds is how long a Pod gets between SIGTERM
	// and SIGKILL on a graceful stop. Zero means 30.
	TerminationGracePeriodSeconds int64 `yaml:"termination_grace_period_seconds,omitempty"`

	// KillGracePeriodSeconds is used for a hard stop. Zero means 5.
	KillGracePeriodSeconds int64 `yaml:"kill_grace_period_seconds,omitempty"`

	// RunAsUser/RunAsGroup override the non-root UID/GID the harness runs
	// as. Zero uses the distroless "nonroot" defaults (65532). Zero is not
	// an accepted *value*: this executor always sets runAsNonRoot.
	RunAsUser  int64 `yaml:"run_as_user,omitempty"`
	RunAsGroup int64 `yaml:"run_as_group,omitempty"`

	// KeepCompletedPods leaves finished Pods in the cluster for debugging
	// instead of deleting them once their logs and status are captured.
	KeepCompletedPods bool `yaml:"keep_completed_pods,omitempty"`

	// OrphanGracePeriodSeconds protects a young Pod from the startup
	// reconcile sweep. Zero means 600.
	OrphanGracePeriodSeconds int64 `yaml:"orphan_grace_period_seconds,omitempty"`

	// MaxConcurrent caps simultaneously-running Pods. Zero means unbounded;
	// the namespace's ResourceQuota is the real ceiling.
	MaxConcurrent int `yaml:"max_concurrent,omitempty"`

	// EgressFilter enforces an egress allowlist with a NetworkPolicy per Pod.
	// Absent means no policy is created and Pods keep the cluster's default
	// egress, which is what an upgrade must not change under an operator.
	EgressFilter KubernetesEgressFilterConfig `yaml:"egress_filter,omitempty"`

	// NetworkPolicyEnforced asserts that this cluster's CNI actually enforces
	// the NetworkPolicy objects cloop creates.
	//
	// It is the one fact this driver needs and cannot obtain. The API server
	// accepts a NetworkPolicy whether or not anything implements it — flannel
	// is the well-known case — so `kubectl get netpol` can list a firewall that
	// does not exist, and a per-project `capabilities.egress` scope on such a
	// cluster would be silently ignored. Placement therefore refuses those
	// projects until somebody settles the question.
	//
	// A *bool with three meanings, because two are not enough:
	//
	//   - unset: nobody has said. A recorded probe verdict decides; with none,
	//     per-project egress scopes are refused. This is every existing
	//     deployment's behaviour and stays that way.
	//   - true: the operator asserts enforcement. Cheapest answer, and only as
	//     good as the person writing it — a probe that contradicts it wins.
	//   - false: the operator states the cluster does *not* enforce policies.
	//     Vetoes any recorded verdict, which is what lets you switch the
	//     capability off the moment a CNI changes rather than waiting for a
	//     stale probe result to age out.
	//
	// The stronger alternative to asserting is `cloop hub doctor
	// --probe-network-policy`, which proves it against the running cluster.
	NetworkPolicyEnforced *bool `yaml:"network_policy_enforced,omitempty"`
}

KubernetesExecutorConfig configures the ephemeral-Pod executor (pkg/executor/kubernetes).

One field is unlike anything in the container section: KubeconfigSecret names a *secret-broker reference*, not a path. This executor has no "kubeconfig file" setting and never will — the credential is leased from pkg/secretbroker for the duration of a run and consumed in memory, so that a control-plane host compromise does not hand over standing cluster access. An operator mints it with `cloop secret mint --kind kubeconfig` and grants it with `cloop secret grant ... --to executor:<id>`.

func (KubernetesExecutorConfig) DriverOptions

func (k KubernetesExecutorConfig) DriverOptions() (kubernetes.Options, error)

DriverOptions converts the config section into driver options, applying the driver's own normalisation and validation.

Credentials is deliberately left nil: only a caller holding a secret broker can fill it in, and that is the wiring in cmd/executor_cmd.go. A config validator asking "is this section well-formed" must not need a decryption key to answer.

type MockConfig

type MockConfig struct {
	// ResponsesFile is the path (absolute or relative to workdir) to a YAML file
	// mapping prompt substrings/hashes to canned responses.
	// Defaults to .cloop/mock_responses.yaml when empty.
	ResponsesFile string `yaml:"responses_file,omitempty"`
	// Default is the response returned when no rule matches.
	// Defaults to "TASK_DONE".
	Default string `yaml:"default,omitempty"`
}

MockConfig holds settings for the deterministic offline mock provider.

type NotifyConfig

type NotifyConfig struct {
	// Desktop enables OS desktop notifications (notify-send on Linux, osascript on macOS).
	Desktop bool `yaml:"desktop,omitempty"`
	// SlackWebhook is the Slack incoming webhook URL.
	// Format: https://hooks.slack.com/services/...
	SlackWebhook string `yaml:"slack_webhook,omitempty"`
	// DiscordWebhook is the Discord webhook URL.
	// Format: https://discord.com/api/webhooks/...
	DiscordWebhook string `yaml:"discord_webhook,omitempty"`
	// CustomWebhook is a generic HTTP webhook URL for custom integrations.
	// cloop POSTs JSON: {"title":"...", "body":"...", "source":"cloop"}
	CustomWebhook string `yaml:"custom_webhook,omitempty"`
}

NotifyConfig holds notification channel settings.

type OIDCConfig

type OIDCConfig struct {
	// Enabled turns OIDC authentication on. Default false.
	Enabled bool `yaml:"enabled,omitempty"`

	// Issuer is the IdP base URL, e.g. https://auth.example.com/realms/main.
	// Discovery is performed at <issuer>/.well-known/openid-configuration.
	// Plain http is only accepted for localhost development IdPs.
	Issuer string `yaml:"issuer,omitempty"`

	// ClientID / ClientSecret identify cloop as a confidential client.
	ClientID     string `yaml:"client_id,omitempty"`
	ClientSecret string `yaml:"client_secret,omitempty"`

	// RedirectURL is the externally reachable callback,
	// e.g. https://cloop.example.com/auth/callback.
	RedirectURL string `yaml:"redirect_url,omitempty"`

	// Scopes requested from the IdP. Default: openid profile email.
	// "openid" is always included.
	Scopes []string `yaml:"scopes,omitempty"`

	// AdminEmails lists users who see and manage every project regardless
	// of per-project ownership. Matched case-insensitively against the
	// email claim. Equivalent to a role_mappings entry with
	// claim: email, role: admin and no scope.
	AdminEmails []string `yaml:"admin_emails,omitempty"`

	// DefaultRole is the role granted to an authenticated identity that
	// matches no entry in RoleMappings. Empty (the default) means "none":
	// deny by default. Set to "viewer" for a read-only-by-default
	// deployment. Valid: none, viewer, operator, maintainer, admin.
	DefaultRole string `yaml:"default_role,omitempty"`

	// RoleMappings maps OIDC claim values to cloop roles, optionally
	// scoped to a single project or executor. See pkg/authz for the
	// permission ladder and the precedence rules.
	//
	//	oidc:
	//	  default_role: none
	//	  role_mappings:
	//	    - claim: group
	//	      value: cloop-admins
	//	      role: admin
	//	    - claim: group
	//	      value: engineering
	//	      role: operator
	//	    - claim: email          # narrower scope overrides the broader
	//	      value: dana@example.com
	//	      role: maintainer
	//	      project: payments
	RoleMappings []RoleMapping `yaml:"role_mappings,omitempty"`

	// SessionTTLHours is the absolute lifetime of a dashboard session — the
	// ceiling set at sign-in, which no amount of activity extends. Zero uses
	// the default (24); values are clamped to 1..720 (30 days).
	SessionTTLHours int `yaml:"session_ttl_hours,omitempty"`

	// IdleTimeoutHours ends a session that has gone unused for this long,
	// even though its absolute ceiling has not been reached. Zero uses the
	// default (8); values are clamped to 1..720 and additionally to
	// SessionTTLHours, since an idle window longer than the ceiling could
	// never fire.
	//
	// This is the clock that bounds an unattended browser, and it is the one
	// most deployments should tighten first: shortening it costs a re-login
	// after a long meeting, while shortening SessionTTLHours interrupts people
	// mid-task.
	IdleTimeoutHours int `yaml:"idle_timeout_hours,omitempty"`

	// RefreshIntervalMinutes is how often cloop re-checks a session against
	// the identity provider using the refresh token issued at sign-in. Zero
	// uses the default (15); values are clamped to 1..1440.
	//
	// It is the worst-case delay between the IdP disabling a user and their
	// cloop session ending, so it is the knob to turn when that lag matters.
	// Set it to -1 to disable the check entirely, which also disables
	// IdP-initiated revocation: the two timeouts then become the only way a
	// session ends. Requires CLOOP_SECRET_KEY, without which refresh tokens
	// are not retained — see docs/security/model.md.
	RefreshIntervalMinutes int `yaml:"refresh_interval_minutes,omitempty"`

	// MaxClaimAgeMinutes bounds how stale a session's group and role claims
	// may be at the moment it exercises a permission above operator —
	// secret.grant, secret.revoke, user.manage, token.admin, session.admin,
	// executor.manage, config.write and the rest of the maintainer and admin
	// tiers. Past it, cloop re-asks the identity provider synchronously and
	// refuses the action if it cannot get an answer. Zero uses the default
	// (5); values are clamped to 1..60.
	//
	// This is the bound on how long a demotion at the IdP can go unnoticed
	// *for actions that matter*, and it is deliberately not the same knob as
	// RefreshIntervalMinutes. That one sets a background cadence and may be
	// switched off entirely; a hub with it disabled still holds this one,
	// because "never re-check before granting a credential" should not be
	// expressible by turning off a periodic task.
	//
	// Read paths are not covered and are not meant to be: they stay on the
	// background cadence so an unreachable IdP makes the dashboard slow to
	// revoke, never slow to serve.
	//
	// Set it to -1 to disable the check. That is the right setting for a
	// deployment with no CLOOP_SECRET_KEY — no refresh token is retained, so
	// claims can never be re-asserted and every privileged action would be
	// refused with an explanation. Turning it off there is an explicit,
	// recorded choice to act on sign-in-time claims.
	MaxClaimAgeMinutes int `yaml:"max_claim_age_minutes,omitempty"`

	// ClockSkewSeconds is the leeway applied to an ID token's exp and iat
	// claims. Zero uses the default (300); set it to -1 for no leeway at
	// all. Values above OIDCClockSkewSecondsUpper are a startup error, not
	// a silent clamp — see oidcauth.MaxClockSkew.
	//
	// The knob exists for deployments that cannot fix the underlying
	// problem: an on-premise IdP on a host whose NTP is blocked by the same
	// firewall that makes it on-premise. Raising it accepts tokens that
	// expired that long ago, so raise it only as far as the observed drift.
	ClockSkewSeconds int `yaml:"clock_skew_seconds,omitempty"`

	// RequireIdP makes an unreachable or misconfigured issuer fatal at
	// startup rather than a warning.
	//
	// Off by default, because the hub can serve existing sessions through a
	// transient IdP outage and refusing to start would turn a login outage
	// into a total one. Turn it on where a failed start is the *desired*
	// signal — a Kubernetes rollout, where a pod that never becomes ready
	// is rolled back automatically, rather than a hub nobody can sign in to
	// quietly replacing one that worked.
	RequireIdP bool `yaml:"require_idp,omitempty"`

	// CookieSecure controls the session cookie's Secure flag:
	// "auto" (default — set when the request arrived over TLS or with
	// X-Forwarded-Proto: https), "always", or "never".
	CookieSecure string `yaml:"cookie_secure,omitempty"`
}

OIDCConfig configures optional OpenID Connect single sign-on for the web dashboard (cloop ui). When Enabled, every browser request must carry a session established via the IdP authorization-code flow; the static bearer token (--token / CLOOP_UI_TOKEN) keeps working for API automation. Projects created through the UI are stamped with the signed-in user's identity and are only visible to that user (and admins); projects without an owner remain shared. Disabled by default — enabling requires issuer, client_id, client_secret, and redirect_url to all be set, otherwise `cloop ui` refuses to start (fail closed).

func (OIDCConfig) EffectiveClockSkew added in v0.0.2

func (o OIDCConfig) EffectiveClockSkew() time.Duration

EffectiveClockSkew returns the leeway to apply to ID-token expiry.

Zero means "not set" and yields the default; a negative value is the explicit "no leeway" opt-out and is passed through as a negative duration, which oidcauth.New normalises to zero. An out-of-range positive value is passed through *unclamped* on purpose, so it reaches the validation that refuses it: an operator who configured an hour of skew must be told, not quietly given ten minutes.

func (OIDCConfig) EffectiveIdleTimeoutHours

func (o OIDCConfig) EffectiveIdleTimeoutHours() int

EffectiveIdleTimeoutHours returns the configured idle window with the zero-default substituted, out-of-band values clamped, and the result held down to the absolute TTL.

The final clamp is what keeps a half-edited config coherent: lowering session_ttl_hours below idle_timeout_hours would otherwise leave an idle clock that can never fire, silently removing the protection the operator most likely believes is on.

func (OIDCConfig) EffectiveMaxClaimAgeMinutes added in v0.0.2

func (o OIDCConfig) EffectiveMaxClaimAgeMinutes() int

EffectiveMaxClaimAgeMinutes returns how stale a session's claims may be before a privileged action re-checks them against the IdP. A negative configured value is passed through as -1, the explicit opt-out; every other out-of-band value is clamped.

func (OIDCConfig) EffectiveRefreshIntervalMinutes

func (o OIDCConfig) EffectiveRefreshIntervalMinutes() int

EffectiveRefreshIntervalMinutes returns how often a session is revalidated against the IdP. A negative configured value is passed through as -1, the explicit "never re-check" opt-out; every other out-of-band value is clamped.

func (OIDCConfig) EffectiveSessionTTLHours

func (o OIDCConfig) EffectiveSessionTTLHours() int

EffectiveSessionTTLHours returns the configured session lifetime with the zero-default substituted and out-of-band values clamped.

type OllamaConfig

type OllamaConfig struct {
	BaseURL string `yaml:"base_url"`
	Model   string `yaml:"model"`

	// Inference parameters (nil = use provider default)
	Temperature *float64 `yaml:"temperature,omitempty"`
	TopP        *float64 `yaml:"top_p,omitempty"`
	MaxTokens   int      `yaml:"max_tokens,omitempty"`
}

type OpenAIConfig

type OpenAIConfig struct {
	// API key (falls back to OPENAI_API_KEY env var)
	APIKey string `yaml:"api_key"`
	Model  string `yaml:"model"`
	// Optional: set for Azure OpenAI or OpenAI-compatible servers
	BaseURL string `yaml:"base_url"`

	// Inference parameters (nil = use provider default)
	Temperature      *float64 `yaml:"temperature,omitempty"`
	TopP             *float64 `yaml:"top_p,omitempty"`
	FrequencyPenalty *float64 `yaml:"frequency_penalty,omitempty"`
	MaxTokens        int      `yaml:"max_tokens,omitempty"`
}

type OrchestratorConfig

type OrchestratorConfig struct {
	// TaskTimeoutMinutes is an optional process-wide wall-clock budget for a
	// single task, used when neither Task.MaxMinutes nor state.DefaultMaxMinutes
	// is set. Zero means no task timeout (Task 20148). A positive value is
	// validated to OrchestratorTaskTimeoutMinutesLower..OrchestratorTaskTimeoutMinutesUpper.
	TaskTimeoutMinutes int `yaml:"task_timeout_minutes,omitempty"`
}

OrchestratorConfig holds wall-clock budgets and other knobs that bound the per-task execution of the PM orchestrator (Task 20108).

The orchestrator already supported a per-task budget via Task.MaxMinutes (Task 99) and a per-project default via state.DefaultMaxMinutes. This config layer adds an optional process-wide default. The lookup order is:

task.MaxMinutes > state.DefaultMaxMinutes > config.Orchestrator.TaskTimeoutMinutes

The first positive value wins; if all are zero there is NO task timeout (Task 20148): by default tasks run until they finish, are killed manually, or the parent run is cancelled. A positive value is an explicit opt-in that caps how long a single task may run; the deadline cancels the per-task context, which propagates into the provider HTTP call (Task 20081 made provider Complete() implementations honor ctx) and the task is marked timed_out.

func (OrchestratorConfig) EffectiveTaskTimeoutMinutes

func (o OrchestratorConfig) EffectiveTaskTimeoutMinutes() int

EffectiveTaskTimeoutMinutes returns the configured task timeout. Zero (the default) means no timeout (Task 20148). Out-of-band positive values are coerced to 0 (no timeout) rather than honoured, matching the orchestrator's effectiveTaskBudgetMinutes resolution.

type QuotaBinding

type QuotaBinding struct {
	// Claim selects what Value is compared against: group, role, email,
	// or sub. Same vocabulary as ui.oidc.role_mappings.
	Claim string `yaml:"claim"`

	// Value is the claim value to match. Group and role values are
	// compared case-insensitively and a leading "/" is ignored, so
	// Keycloak's group-path form works as written.
	Value string `yaml:"value"`

	// Limits are the ceilings this binding contributes. Valid keys are
	// max_projects, max_concurrent_tasks, max_executors, max_sessions,
	// daily_token_budget and daily_cost_usd. Only the keys present are
	// affected; the rest keep resolving from less specific bindings.
	Limits map[string]float64 `yaml:"limits,omitempty"`
}

QuotaBinding is one claim→limits binding in ui.quotas.bindings. It mirrors quota.Binding; cmd/ui_cmd.go converts between the two so pkg/config stays free of quota logic and pkg/quota stays free of YAML — the same split RoleMapping has with authz.Binding.

type QuotasConfig

type QuotasConfig struct {
	// Defaults apply to every identity no binding covers.
	Defaults map[string]float64 `yaml:"defaults,omitempty"`

	// Bindings narrow (or widen) limits per claim value.
	Bindings []QuotaBinding `yaml:"bindings,omitempty"`
}

QuotasConfig is the per-identity admission policy (Task 20182).

It lives next to ui.oidc.role_mappings on purpose: RBAC decides whether an identity may act, this decides how much, and the two are read together when answering "what can this tenant do to my hub?". Keeping the bulk of quota policy in config rather than in the database also means it is reviewable, diffable and deployable like the role mappings it sits beside; the Quotas panel's per-identity edits are the escape hatch, stored separately and stamped with who made them.

ui:
  quotas:
    defaults:
      max_projects: 3
      max_concurrent_tasks: 1
      daily_token_budget: 500000
    bindings:
      - claim: group
        value: engineering
        limits:
          max_projects: 25
          max_concurrent_tasks: 4
      - claim: email
        value: sre@example.com
        limits:
          max_executors: 50

Resolution is per-resource and most-specific-wins (sub > email > role > group > defaults). Within one tier the *smallest* ceiling wins, so joining another group can never raise a tenant's own cap. See pkg/quota.

func (QuotasConfig) Configured

func (q QuotasConfig) Configured() bool

Configured reports whether any quota policy was written.

type RateLimitConfig

type RateLimitConfig struct {
	// RequestsPerSecond is the sustained request rate allowed per remote IP.
	// Default: 20 requests/second.
	RequestsPerSecond float64 `yaml:"requests_per_second,omitempty"`
	// Burst is the maximum burst size (bucket capacity) per remote IP.
	// Default: 50 requests.
	Burst int `yaml:"burst,omitempty"`
}

RateLimitConfig controls the per-IP token-bucket rate limiter applied to the REST API server (cloop serve) and the Web UI server (cloop ui).

type RetentionConfig

type RetentionConfig struct {
	// Enabled turns the janitor off when set to false. Pointer-valued so an
	// absent key is distinguishable from an explicit false — absent means
	// "use the default", which is on.
	Enabled *bool `yaml:"enabled,omitempty"`

	// IntervalHours is how often a pass runs. Zero selects the default of 24.
	// Validated to RetentionIntervalHoursLower..RetentionIntervalHoursUpper.
	IntervalHours int `yaml:"interval_hours,omitempty"`

	// KeepSnapshots bounds .cloop/plan-history, enforced both by the janitor
	// and by pm.SaveSnapshot at write time. Zero selects
	// pm.DefaultSnapshotRetention. Validated to
	// RetentionKeepSnapshotsLower..RetentionKeepSnapshotsUpper.
	KeepSnapshots int `yaml:"keep_snapshots,omitempty"`

	// ArchiveMaxMB caps the total size of .cloop/audit-archive. Zero — the
	// default — keeps every seal, because a seal is the only remaining copy
	// of the rows it holds. See janitor.Policy.ArchiveMaxBytes.
	ArchiveMaxMB int `yaml:"archive_max_mb,omitempty"`

	// ArchiveMaxAgeDays deletes seals older than this. Zero — the default —
	// keeps every seal. See ArchiveMaxMB.
	ArchiveMaxAgeDays int `yaml:"archive_max_age_days,omitempty"`

	// VacuumFreeRatio is the freelist fraction at which the janitor vacuums.
	// Zero selects janitor.DefaultVacuumFreeRatio; 1 or more disables
	// vacuuming and is accepted as-is. Values between zero and
	// RetentionVacuumFreeRatioLower are rejected as nonsense.
	VacuumFreeRatio float64 `yaml:"vacuum_free_ratio,omitempty"`

	// VacuumMinFreeMB is an absolute floor below which the ratio is ignored,
	// so a small database is not rewritten daily to reclaim a few hundred
	// kilobytes. Zero selects janitor.DefaultVacuumMinFreeBytes.
	VacuumMinFreeMB int `yaml:"vacuum_min_free_mb,omitempty"`

	// VacuumMaxInlineMB caps the live data the hub will rewrite *without
	// being stopped*. The control-plane database holds the hub's own lease,
	// so a VACUUM that outlasts the lease TTL starves its heartbeat and the
	// hub stands down believing another instance took over. Above this bound
	// the janitor declines and points at `cloop hub retention --apply`, which
	// has no heartbeat to lose. Zero selects
	// janitor.DefaultVacuumMaxInlineBytes.
	VacuumMaxInlineMB int `yaml:"vacuum_max_inline_mb,omitempty"`

	// ProviderCallBodyAgeDays clears the recorded prompt, system prompt and
	// response of provider-call rows older than this, keeping the row's
	// metadata — which is what the inspector list and the cost reports read.
	// Default 30. Validated to
	// RetentionProviderCallBodyAgeDaysLower..Upper.
	ProviderCallBodyAgeDays int `yaml:"provider_call_body_age_days,omitempty"`

	// ProviderCallMaxRows, StepMaxRows, EventMaxRows and CostMaxRows are row
	// ceilings; the newest rows survive. Validated to
	// RetentionRowsLower..RetentionRowsUpper.
	ProviderCallMaxRows int `yaml:"provider_call_max_rows,omitempty"`
	StepMaxRows         int `yaml:"step_max_rows,omitempty"`
	EventMaxRows        int `yaml:"event_max_rows,omitempty"`
	CostMaxRows         int `yaml:"cost_max_rows,omitempty"`

	// TelemetryMaxAgeDays deletes browser telemetry older than this. Age
	// rather than a row count because statedb already caps that table on the
	// write path — a public ingest endpoint must not be able to fill a disk
	// between two passes — leaving only the question a write-time cap cannot
	// answer: when does a trail nobody returned to expire. Default 30.
	TelemetryMaxAgeDays int `yaml:"telemetry_max_age_days,omitempty"`
}

RetentionConfig is the policy for the in-hub retention janitor (Task 20229).

It is the opposite default to AuditConfig, on purpose. Audit retention is opt-in because it governs a compliance record. This governs plan snapshots, which are a derived convenience, and SQLite freelist pages, which are not data at all — so leaving it off by default would mean shipping a hub that still fills its disk, which is the defect.

Every field's zero value selects the package default rather than "off", so an existing config.yaml that has never heard of this section gets the working policy. Disabling is explicit: `enabled: false`.

func (RetentionConfig) RetentionEnabled

func (r RetentionConfig) RetentionEnabled() bool

RetentionEnabled reports whether the janitor should run, resolving the absent-means-default pointer.

type RoleMapping

type RoleMapping struct {
	// Claim selects what Value is compared against: group, role, email,
	// or sub.
	Claim string `yaml:"claim"`

	// Value is the claim value to match. Group and role values are
	// compared case-insensitively; a leading "/" is ignored so Keycloak's
	// "/cloop-admins" group-path form works as written.
	Value string `yaml:"value"`

	// Role is granted to matching identities: viewer, operator,
	// maintainer, or admin (or none, to explicitly deny within a scope).
	Role string `yaml:"role"`

	// Project narrows the binding to one project, matched against either
	// the project's registry name or its filesystem path. Empty means
	// every project.
	Project string `yaml:"project,omitempty"`

	// Executor narrows the binding to one executor ID. Empty means every
	// executor.
	Executor string `yaml:"executor,omitempty"`
}

RoleMapping is one claim→role binding in oidc.role_mappings. It mirrors authz.Binding; pkg/ui converts between the two so pkg/config stays free of authorization logic and pkg/authz stays free of YAML.

type RouterConfig

type RouterConfig struct {
	// Routes maps role name (backend, frontend, testing, security, devops, data, docs, review)
	// to a provider name (anthropic, openai, ollama, claudecode).
	Routes map[string]string `yaml:"routes,omitempty"`
}

RouterConfig maps AgentRole names to provider names. Roles not listed here use the default provider.

type STTConfig added in v0.0.2

type STTConfig struct {
	// Provider pins the backend: "groq" or "whisper". Empty auto-selects.
	Provider string `yaml:"provider,omitempty"`
	// GroqAPIKey authenticates hosted Whisper (falls back to GROQ_API_KEY).
	GroqAPIKey string `yaml:"groq_api_key,omitempty"`
	// Model overrides the hosted model. Defaults to whisper-large-v3-turbo.
	Model string `yaml:"model,omitempty"`
	// Endpoint overrides the transcription URL for an OpenAI-compatible
	// server that is not Groq.
	Endpoint string `yaml:"endpoint,omitempty"`
	// Language is an ISO-639-1 hint ("en", "de"). Empty means auto-detect,
	// which is the right default for a hub whose users do not share one.
	Language string `yaml:"language,omitempty"`
	// WhisperModel selects the local CLI model: base, small, medium, large.
	WhisperModel string `yaml:"whisper_model,omitempty"`
}

STTConfig holds speech-to-text settings for dictated tasks (Task 20238).

The backend order is the Lisa project's: hosted Whisper on Groq when a key is configured, the local openai-whisper CLI otherwise. Leaving every field empty is a valid configuration — it resolves to whichever of the two is actually available, and the microphone button stays hidden when neither is.

type SandboxConfig

type SandboxConfig struct {
	// ImagePolicy decides which container images a project may run in.
	// Absent means no constraint, which is the single-developer default: a
	// laptop's `image: python:3.12` must keep working after an upgrade.
	ImagePolicy ImagePolicyConfig `yaml:"image_policy,omitempty"`
}

SandboxConfig groups the constraints applied to per-project sandbox specs.

type SyncConfig

type SyncConfig struct {
	// Remote is the git remote name to sync with (default "origin").
	Remote string `yaml:"remote,omitempty"`
	// Branch is the branch name used to push/pull cloop state (default "cloop-state").
	Branch string `yaml:"branch,omitempty"`
}

SyncConfig configures git-based team plan sharing.

type TLSConfig

type TLSConfig struct {
	// CertFile is the PEM certificate chain, leaf first.
	CertFile string `yaml:"cert_file,omitempty"`

	// KeyFile is the PEM private key. It should be mode 0600; a
	// world-readable server key is a complete impersonation of the control
	// plane, so cloop warns loudly (but still starts) when it is not.
	KeyFile string `yaml:"key_file,omitempty"`

	// MinVersion is the TLS floor: "1.2" (default) or "1.3". TLS 1.0 and 1.1
	// are rejected — they have no safe configuration left.
	MinVersion string `yaml:"min_version,omitempty"`
}

TLSConfig configures native TLS termination for cloop's HTTP servers.

cloop can serve HTTPS itself, or sit behind a proxy that does. Both are supported; what is not supported is a half-configuration. Setting exactly one of cert_file/key_file is an error at startup rather than a silent fallback to plaintext, because "the operator asked for TLS and got HTTP without being told" is precisely the failure this exists to prevent.

func (TLSConfig) Enabled

func (t TLSConfig) Enabled() bool

Enabled reports whether native TLS is configured.

func (TLSConfig) Validate

func (t TLSConfig) Validate() error

Validate rejects a half-configured TLS block. Called by the ui/serve commands before binding a listener.

type TelemetryConfig added in v0.0.2

type TelemetryConfig struct {
	// Enabled is a pointer so that absent and explicitly-false are
	// distinguishable. A plain bool would make the zero value — an operator
	// who has never heard of this setting — mean "off", which is the opposite
	// of the intended default.
	Enabled *bool `yaml:"enabled,omitempty"`
}

TelemetryConfig is the switch for browser telemetry collection (Task 20251).

ui:
  telemetry:
    enabled: false

Default on, which is the unusual choice and the deliberate one. The trails exist to diagnose front ends the hub cannot otherwise observe — above all the display-glasses page, which has no console and no network inspector — and an instrument that has to be switched on in advance is never on when the failure it was built for happens.

What that default costs is bounded rather than argued: events are scrubbed of credentials at ingest, clamped to fixed field budgets, capped per batch, and stored in a table that trims itself on the write path. Reading them back takes audit.read.

The switch exists because "bounded" is not the same as "acceptable to everyone": a deployment whose policy forbids storing user-agent strings or client addresses at all needs a way to say so, and turning it off makes both ingest routes answer 404 rather than silently discarding.

func (TelemetryConfig) Effective added in v0.0.2

func (t TelemetryConfig) Effective() bool

Effective reports whether collection is on, applying the default-on rule.

type TracingConfig

type TracingConfig struct {
	// Enabled activates OTLP trace export. Default: false (no-op).
	Enabled bool `yaml:"enabled,omitempty"`
	// Endpoint is the OTLP HTTP receiver base URL, e.g. "http://localhost:4318".
	// The exporter appends /v1/traces automatically.
	Endpoint string `yaml:"endpoint,omitempty"`
	// ServiceName is reported as the OTel service.name resource attribute.
	// Defaults to "cloop".
	ServiceName string `yaml:"service_name,omitempty"`
}

TracingConfig holds OpenTelemetry tracing settings.

type UIConfig

type UIConfig struct {
	// AutoResumeOnCapReset lets the hub restart a run that a Claude Code
	// subscription cap stopped, once the window it was waiting on has rolled
	// over (Task 20285).
	//
	// A *bool rather than a bool because absent and explicitly-false must
	// differ: the default is on, so a plain bool's zero value would silently
	// disable the feature for every existing config file.
	//
	// On is the right default. The alternative is what this replaced — an
	// unattended fleet parked until a human notices, for a condition that had
	// already cleared and whose exact reset time the hub already knew. It only
	// ever restarts a run paused *solely* for a usage cap: a declined
	// approval, a spent budget or an operator's Stop are never resumed on a
	// timer, because each of those is a decision the resume would override.
	// Operators who want a human in that loop can set it to false.
	AutoResumeOnCapReset *bool `yaml:"auto_resume_on_cap_reset,omitempty"`

	// MaxWebSocketConns caps the total number of concurrent WebSocket
	// connections the UI server will accept across all remote IPs.
	// Zero substitutes WebSocketConnsDefault (256).
	// Validated to WebSocketConnsLower..WebSocketConnsUpper.
	MaxWebSocketConns int `yaml:"max_websocket_conns,omitempty"`

	// MaxWebSocketConnsPerIP caps the number of concurrent WebSocket
	// connections accepted from any single remote IP. A breached cap
	// returns HTTP 429 with a Retry-After header; the connection is
	// rejected before nhooyr.Accept hijacks the request.
	// Zero substitutes WebSocketConnsPerIPDefault (8).
	// Validated to WebSocketConnsPerIPLower..WebSocketConnsPerIPUpper.
	MaxWebSocketConnsPerIP int `yaml:"max_websocket_conns_per_ip,omitempty"`

	// MaxRequestBodyBytes caps the size of any incoming HTTP request body
	// the UI server will accept (POST/PUT/PATCH on every /api/* endpoint).
	// Zero substitutes MaxRequestBodyBytesDefault (10 MiB). Validated to
	// MaxRequestBodyBytesLower..MaxRequestBodyBytesUpper. A breached cap
	// returns HTTP 413 (Request Entity Too Large) before the handler runs,
	// so a malicious or buggy client cannot OOM the daemon by streaming
	// a multi-GB body. Applies to both cloop ui and cloop serve.
	MaxRequestBodyBytes int64 `yaml:"max_request_body_bytes,omitempty"`

	// AllowedWSOrigins lists additional Origin hosts permitted to open a
	// WebSocket to the dashboard, on top of the always-allowed loopback
	// origins and same-origin requests (where the Origin host matches the
	// request Host). This is required when the dashboard is served behind a
	// reverse proxy on a public hostname AND the browser's Origin differs
	// from the Host the server sees (e.g. proxy rewrites Host). Each entry
	// is a host or host:port pattern accepted by the websocket library,
	// e.g. "aiden.example.com" or "aiden.example.com:1234". Same-origin
	// requests already work without listing anything here.
	AllowedWSOrigins []string `yaml:"allowed_ws_origins,omitempty"`

	// AllowedOrigins lists browser Origins permitted to open a WebSocket to
	// *any* endpoint of this hub, including the executor-agent endpoint at
	// /api/executors/connect. It is the deployment-wide setting;
	// AllowedWSOrigins remains the dashboard-only one and both are honoured
	// for the dashboard. Entries may be full origins
	// ("https://cloop.example.com"), host:port, or bare hosts.
	//
	// Loopback and same-origin requests are always allowed and need no entry
	// here — this exists for the reverse-proxy case where the browser's
	// Origin and the Host the server sees genuinely differ.
	AllowedOrigins []string `yaml:"allowed_origins,omitempty"`

	// ExternalURL is what this deployment calls itself, e.g.
	// https://cloop.example.com. Its host is always an accepted Origin, so a
	// correctly-set external URL usually makes allowed_origins unnecessary.
	// It is also what `cloop executor enroll` puts in enrollment bundles when
	// --server is not passed.
	ExternalURL string `yaml:"external_url,omitempty"`

	// TLS configures native HTTPS for `cloop ui` and `cloop serve`.
	// Disabled (plaintext) when cert_file and key_file are unset, which is
	// correct for loopback development and for a deployment that terminates
	// TLS in a reverse proxy.
	TLS TLSConfig `yaml:"tls,omitempty"`

	// OIDC configures optional OpenID Connect single sign-on for the web
	// dashboard. Disabled by default; see OIDCConfig.
	OIDC OIDCConfig `yaml:"oidc,omitempty"`

	// Quotas caps how much each identity may consume on a shared hub.
	// Empty (the default) means unlimited, which is what keeps a
	// single-tenant deployment untouched. See QuotasConfig.
	Quotas QuotasConfig `yaml:"quotas,omitempty"`

	// Telemetry governs collection of browser-side diagnostic trails.
	// On by default; see TelemetryConfig.
	Telemetry TelemetryConfig `yaml:"telemetry,omitempty"`

	// CI federates CI/CD pipeline OIDC identities and relays their Anthropic
	// calls. Disabled by default; see CIConfig.
	CI CIConfig `yaml:"ci,omitempty"`
}

UIConfig holds settings for the cloop ui Web dashboard server.

WebSocket caps (Task 20090) protect the server from goroutine exhaustion caused by accidental browser tab storms or deliberate connection floods. Each accepted upgrade spawns at least three goroutines (handler, drain, writer-loop ticker) plus an entry in the per-project hub registry; without caps a single client can register thousands of simultaneous peers.

func (UIConfig) EffectiveMaxRequestBodyBytes

func (u UIConfig) EffectiveMaxRequestBodyBytes() int64

EffectiveMaxRequestBodyBytes returns the configured request-body cap, substituting MaxRequestBodyBytesDefault (10 MiB) when the field is zero. Used by both pkg/ui and pkg/apiserver to size their http.MaxBytesReader wrappers.

func (UIConfig) EffectiveMaxWebSocketConns

func (u UIConfig) EffectiveMaxWebSocketConns() int

EffectiveMaxWebSocketConns returns the configured total cap, substituting WebSocketConnsDefault when the field is zero (the YAML "not set" sentinel).

func (UIConfig) EffectiveMaxWebSocketConnsPerIP

func (u UIConfig) EffectiveMaxWebSocketConnsPerIP() int

EffectiveMaxWebSocketConnsPerIP returns the configured per-IP cap, substituting WebSocketConnsPerIPDefault when the field is zero.

type WatchConfig

type WatchConfig struct {
	// Globs are file patterns to monitor (e.g. "**/*.go").
	// Used as defaults when --glob is not specified on the command line.
	Globs []string `yaml:"globs,omitempty"`
	// Debounce is the duration to wait after the last change before triggering
	// (e.g. "2s", "500ms"). Defaults to 2s.
	Debounce string `yaml:"debounce,omitempty"`
}

WatchConfig configures file-triggered plan re-evaluation.

type WebhookConfig

type WebhookConfig struct {
	// URL to POST events to (empty = disabled).
	URL string `yaml:"url,omitempty"`
	// Events to fire (empty = all). Valid values:
	//   session_started, session_complete, session_failed,
	//   task_started, task_done, task_failed, task_skipped,
	//   plan_complete, evolve_discovered
	Events []string `yaml:"events,omitempty"`
	// Optional HTTP headers added to every request (e.g. Authorization).
	Headers map[string]string `yaml:"headers,omitempty"`
	// Secret, if set, signs every request with HMAC-SHA256 in the
	// X-Hub-Signature-256 header (GitHub-style webhook signing).
	Secret string `yaml:"secret,omitempty"`
}

WebhookConfig holds outbound notification settings.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL