Security Model
What RunLore is allowed to do, how that’s enforced, and the honest limitations. This is the runtime
security model — how the agent behaves in your cluster. For the LLM-specific trust story — prompt
injection, redaction boundaries, untrusted-output handling, network guards — see the
LLM security architecture. For reporting a vulnerability, see
SECURITY.md. For the deeper design rationale, see Design §9.
The guiding principle: safety is enforced in code, not promised in prose. The agent’s own claims (and the LLM’s output) are never trusted for an authorization decision.
Read-only by default
RunLore reads your cluster, metrics, logs, and network — its only writes are markdown to Git via reviewed PRs. Two independent layers keep it that way:
- RBAC grants no write verbs by default. The ServiceAccount gets
get/list/watchcluster-wide and nothing else (see RBAC below). - The action policy defaults to
off(actions.mode). No cluster-mutating tool is wired to the LLM; the model can only propose an action, never dispatch one.
The Curator is cluster-read-only — its “writes” are PRs and issues against your knowledge-base repo, never the cluster.
The action gate (climbing the autonomy ladder)
When you enable actions (the suggest → approve → auto ladder), every executable action passes a
server-authoritative gate (internal/action) that re-derives its safety from a canonical op
registry and discards the model’s own metadata:
- Reversibility and blast radius come from the registry (only
suspend/resume/reconcileare executable, all reversible, blast-radius 1) — an unknown op is treated as irreversible and refused. - The policy envelope enforces
reversible_only,max_blast_radius, an allowed-kindslist, and a namespace allowlist.flux-systemandkube-systemare always denied as targets, regardless of config; an emptyallow.namespacespermits nothing. - The gate is re-validated at every execution boundary (approval handling and auto), so a stale or tampered decision can’t slip through (defense in depth).
- The target name is corroborated against the resources the investigation actually observed
server-side (the triggering workload + everything the GitOps read tools returned). An unobserved
target never auto-executes (the action is downgraded to a suggestion); under
approveit stays in the queue but carries an explicit possible-injection warning for the human approver. automode starts paused (kill-switch engaged, fail-closed) and is gated behind confidence/rate/blast limits. It exists but is not recommended on real clusters.
The action config is fail-closed: approve/auto won’t start without an approval token and an
audit-log path (both modes execute cluster mutations, so both must be audited), and auto
additionally requires an authenticated webhook, a positive confidence
threshold and rate cap, and a non-empty namespace allowlist (see
Configuration → actions).
External MCP tools
Remote MCP tools run outside RunLore’s action gate: the gate stops RunLore from executing cluster
operations, but a remote tool that mutates state server-side would do so the moment it is called.
Treat every configured MCP server as part of your TCB. Two controls bound this: per-server
mcp.servers[].tools allowlists (a tool not listed is never registered, so the model can never call
it), and mcp.require_allowlist: true to refuse startup unless every server is allowlisted. Tool
output remains untrusted data (redaction + no-instruction-following), and per-server discovery
failures are isolated.
Secret redaction at the LLM and egress boundaries
Tool output and incident text flow to a model provider and, for findings, into your KB PR and chat.
internal/redact masks secret-shaped values at three boundaries:
- Ingress — incident text before it enters the prompt.
- Tool output — every tool result (pod/controller logs, git diffs, status/event messages) before it reaches the model provider.
- Delivery — the finished investigation (root-cause summaries, evidence, suggested actions) before it’s copied into the KB PR body and chat.
Coverage (high-precision, masks the value while keeping structure): PEM private keys, JWTs,
GitHub / Slack / AWS / Google / Stripe keys, user:pass@host URLs, Authorization headers,
generic *(password|secret|api_key|token|…): <value> pairs, and the values under a kind: Secret
manifest’s data:/stringData: block — including one surfaced inside a git diff. A masked
Secret value is also learned: its base64 blob is decoded and both forms are scrubbed from the
whole payload, so the same secret quoted decoded in a log line or encoded in an event does not
outlive the manifest that names it. One shape is masked structurally rather than textually: a
container’s env puts the sensitive word in the value of name: and the credential under the
literal key value:, which no key-name rule can see, so resource_spec walks the decoded object
and masks it before anything is rendered.
Warning
Redaction is a mitigation, not a guarantee
The ruleset is deliberately high-precision, and the cost of precision is recall: unlabeled
high-entropy strings, bare AWS secret keys with no context cue, and base64 blobs whose kind: Secret manifest is not in the same payload (decoding happens only with the manifest as
ground truth — a lone blob is indistinguishable from a SHA or log blob) are not caught — see
LLM security architecture §2
for the full list. If you run a public KB repo or untrusted-tenant namespaces, treat this
as a gating concern — and prefer self-hosting the model (in-cluster vLLM/Ollama), which keeps
data in-boundary regardless.
Least-privilege RBAC
The chart’s RBAC is scoped tightly (deploy/helm/runlore/templates/rbac.yaml):
- ClusterRole (read-only, cluster-wide):
get/list/watchon Flux/ArgoCD resources andevents,get/listonpods(status only — notpods/log). No write verb.patchis intentionally never granted cluster-wide. - Namespaced Role for pod/controller logs:
pods/log(raw log bodies, which can carry secrets/PII) is granted only overrbac.controllerLogNamespaces(defaultflux-system) — never cluster-wide. - Defense-in-depth app-layer guard: because pod logs are streamed to the external LLM, the
pod_logstool is also constrained in the agent config to {the incident’s own namespace} ∪config.investigation.pod_log_namespaces— a request for any other namespace is rejected before the cluster is queried, not just denied by RBAC. The chart auto-defaultspod_log_namespacestorbac.controllerLogNamespaces, so the app-layer allowlist tracks the RBAC scope by default (no silent drift); leaving both at the defaults limits raw-log reads to the incident namespace plusflux-system. The app guard must stay a superset of the RBAC namespaces, orpod_logsis blocked at the app layer for namespaces RBAC would otherwise permit. resource_specandlist_resources’ read-only kind allowlist:rbac.resourceSpecRulesgrantsgetandliston the spec-bearing kinds these tools read (Services, workloads, NetworkPolicies, HPAs, PVCs, PVs, Nodes, scrape CRs …). Both verbs are needed because two tools share the rules:resource_specreads one object by name (get), whilelist_resourcesenumerates a kind (list) and is registered automatically alongside it. Grantinggetalone leaveslist_resourcespermanently denied — and the agent reports that as a data gap rather than an error, so it reasons around the missing evidence and nothing says why. Adding a rule REPLACES this list rather than merging into it — a values overlay or Kustomize patch that names one CRD leaves you with that one rule and silently drops the shipped ten, with no install-time error. To extend, copy the whole list and append. It ships populated — this is a default grant, not an opt-in menu: a stock install adds 10 rules — 28 resource types, 23 of them granted nowhere else — of cluster-widegetandlist, on top of the ClusterRole’s 9 base rules. Setrbac.resourceSpecRules: []to decline it in full (that costs youresource_specand nothing else —workload_ownership’s owner-chain kinds are granted separately and unconditionally, so narrowing this list can never silently truncate an owner chain). Kinds with neither.specnor.status— ServiceAccount, EndpointSlice, StorageClass, ConfigMap — are deliberately absent: the read would return nothing while the grant stayed entirely real, and a cluster-wide ServiceAccount read in particular exposes the IRSA / Workload-Identity role ARNs in its annotations. It is an allowlist, never a wildcard, on purpose:resources: ["*"]includessecrets. The tool refuses theSecretkind before and after resolution — a spelling that case-folds to “secret”, and any resource literally namedsecretsin any API group, is refused — but that is an application-layer policy. RBAC is the boundary. A kind missing from the list is reported to the model as a denial, never as a missing object, so a gap degrades the tool instead of fabricating evidence.- Namespaced Role for actions: only when
rbac.allowActionsis set,get/patchonkustomizations/helmreleasesoverrbac.actionNamespaces— a bounded, opt-in blast radius that must mirrorconfig.actions.allow.namespaces.
Credentials & the GitHub App
- Short-lived tokens, no PAT. The GitHub App mints an RS256 JWT (~9 min), exchanges it for a ~1-hour installation token, and refreshes ~1 minute before expiry. There is no long-lived personal access token; revocation is central (uninstall the App).
- Scope the App to the KB repo (Contents/PRs/Issues read-write), plus optional read-only on your GitOps source repos for the what-changed diff. Disable the App’s webhook. See Getting started → GitHub App.
- The clone credential is confined to one host — on the paths that read a repo URL from the
cluster. RunLore attaches the forge token only to clones of
forge.git_host(derived fromgithub_api_url/gitlab.base_urlunless you set it); any other host clones anonymously. This matters because a GitOpsspec.source.repoURLis cluster state — a namespace admin who can create an Argo CDApplicationor a FluxGitRepositorywould otherwise choose where the token is sent. A GitHub Enterprise install with subdomain isolation must nameforge.git_host, and fails config load until it does, so the credential is never quietly withheld from your own GitOps repo either. The confinement is implemented as theTokenHostfield on the differ thatwhat_changedandsource_diffshare. - The catalog git-sync is outside that confinement, and rests on a different argument. The
catalog syncer has no host field, so it sends its credential —
catalog.git.token_env, or the shared forge GitHub App identity when that is unset — to whatevercatalog.git.urlnames, on any host.forge.git_hostdoes not gate this path and setting it changes nothing here. The reason this is not the same exposure as the GitOps case is thatcatalog.git.urlis operator config, not cluster state: changing it means changingrunlore.yaml, which no namespace admin can do. Treat it accordingly — point it at a host you would hand the forge credential to, and give it a dedicated read-scopedcatalog.git.token_envwhen the catalog lives somewhere the forge App identity should not reach. - Secrets by indirection. Every credential is referenced by the name of an env var / Secret key, never inlined in config — so config can’t leak a secret (see Configuration).
- Webhook auth. The incident webhook accepts a bearer token (
server.webhook_token_env). It is mandatory once any model is configured (theservepath fails closed — an unauthenticated webhook must not reach the LLM and bill the model) and also enforced byconfig.Validateunderactions.mode=auto. It is warning-only for the model-less log-only investigator. Pair it with a restrictive NetworkPolicy. - Failed-auth backoff. Failed authentications on the control endpoints and the alert webhook are
rate-limited per remote host: after 10 consecutive failures the host is blocked for 1s, doubling up
to a 60s cap, and the block is checked before the token compare. A correct token always clears the
counter. Behind a shared NAT this can delay a legitimate caller for at most one block window during
a live attack. Tokens should be ≥128-bit random values (e.g.
openssl rand -hex 16); the backoff is a brake on weak tokens, not a substitute for a strong one.
Tamper-evident audit log
Every action attempt — inputs, gate result, op, target, actor, outcome — is appended to a
hash-chained JSON log (internal/audit): each record carries the previous record’s hash, the file
is 0600 and fsync’d after every write, and a Verify pass detects the first broken link. Outcomes
recorded: executed / dry-run / skipped / denied / failed.
The chain is load-bearing, not just an artifact tests check:
- Verified on startup, fail-closed under
approve/auto. Both executing modes are required to setactions.audit_log_path(enforced by config validation), so the guarantee always has a chain to verify — neither can silently downgrade to an unaudited run. When the agent opens the log it re-walks the existing chain in a single read pass and reuses that same handle for appends (no verify→append re-read window). If a link is broken andactions.modeisapproveorauto, startup fails — RunLore refuses to execute and audit cluster mutations against a history it can no longer vouch for. Underoff/suggest(nothing executes) it logs a loud warning and keeps appending, so a read-only deployment isn’t blocked by a damaged file. An empty or absent log is a valid (zero-record) chain. - Verifiable on demand.
lore audit verify --path <audit.jsonl>(or--config <runlore.yaml>to readactions.audit_log_path) re-walks the chain out-of-band: it printsOK: chain intact (<N> records)and exits0, or prints the first broken link and exits non-zero. Run it from CI, a cron, or an incident review.
Verification catches insertion, edit (any byte of a recorded field), and mid-chain deletion
— each breaks a prev_hash/hash link.
Honest residual limit — tail-truncation. Dropping the most-recent records leaves a shorter but internally consistent prefix, which still verifies. Chain verification alone therefore cannot detect that the tail was lopped off. Fully closing this needs an external anchor (e.g. periodically publishing the head hash + record count to an append-only store the writer can’t rewrite), which is out of scope: a sidecar high-water mark doesn’t help — a privileged writer that can truncate the log can truncate the sidecar too, and making it crash-consistent is fiddly. Until an external anchor exists, mitigate operationally: keep the log on durable storage with restricted write access (ideally a medium where the agent’s own identity cannot rewrite history), and back it up.
The feedback channels (👍/👎) — exposure & trust model
Human feedback ratings weigh recalled-knowledge trust and re-arm the recurrence cooldown, so the channels that carry them are part of the security surface. The two channels have opposite exposure profiles and one shared trust model.
Shared trust model — votes are workspace/room-scoped opinions. Feedback is deliberately unprivileged: any authenticated member of your Slack workspace / Matrix room can rate (it is an opinion feeding the learning loop, not a cluster mutation — approve/reject keep their allowlist). The blast radius of a hostile voter is bounded by construction: one live vote per (incident trigger, user), latest wins (no stacking), a vote is one Bernoulli observation in the same Beta posterior as resolve signals (several independent voters are needed to move an established entry), recalled answers still pass the adversarial verify pass, recall confidence is hard-capped at 0.90, and the worst a 👎 campaign achieves is extra fresh investigations (cost, not wrong answers — decay fails toward re-investigation, never toward trusting). Every vote is an append-only ledger line carrying the voter’s stable id, so a campaign is auditable after the fact.
Slack (notify.slack.feedback_buttons) — an exposed endpoint, hardened. Clicks arrive on
POST /slack/interactions, which must be reachable from Slack’s servers. Every request is verified
against the app signing secret (HMAC-SHA256 over the raw body, ±5-minute timestamp window
against replay, constant-time compare) before any parsing-derived action; unsigned or stale
requests are rejected and the body read is capped at 1 MiB. Replay within the window is idempotent
by the vote dedup. The message-update callback (response_url) is restricted to
https://*.slack.com with a bounded client (no SSRF). Expose only the paths you use, not the
pod: route /slack/interactions — and, if thread capture
is also enabled, /slack/events too — through your ingress/gateway, and nothing else; the same
listener also serves the alert webhook (open when server.webhook_token_env is unset!), /metrics,
and the token-gated control endpoints, none of which belong on the internet. If any part of the
server is reachable from outside, set server.webhook_token_env regardless of action mode.
Matrix (notify.matrix.feedback_reactions) — nothing exposed, one explicit check. Reactions
arrive over the client-server /sync long-poll — an outbound HTTPS request authenticated by
the notifier’s existing access token. No inbound endpoint, no signing secret, no NetworkPolicy
change; responses are size-capped before decoding. The one attack Matrix enables that Slack cannot
is attribution forgery: any room member could post their own message carrying the
io.runlore.trigger_key content field and vote on it, misdirecting ratings to an arbitrary
incident. The listener closes this by resolving its own identity (/whoami) at startup and
counting a vote only when the reacted-to event was sent by the bot itself — and it refuses to
listen at all until that identity is known. Operational requirement: because vote identity is room
membership, use an invite-only room (and prefer disabling federation for it); in a federated
room, remote homeservers assert their own users’ identities.
Slack thread capture (notify.slack.thread_capture) — a second exposed endpoint
Opt-in and separate from the feedback channels above: replying @runlore note: … inside an
investigation thread lets a human write what they know straight into a knowledge-base PR, never
applied automatically — but it is not reviewed and merged like any other curated entry. It has
its own, deliberately different lifecycle: a note PR is markerless by design (no DupFingerprint),
so curate’s dedup pass never auto-closes it as a “duplicate” of another note on the same recurring
incident — closing one outright would discard a human’s contribution. It is not exempt from the
stale sweep, though: an untouched note past the configured staleness window is closed like any other
stale draft, with a comment that says so and invites reopening — routine housekeeping, not a
rejection, and nothing is discarded (reopening the PR restores it for review). It shares the Slack
interactivity callback’s exposure story above, but none of its vote trust model, which is why it
gets its own section here instead of folding into that one.
Mentions arrive on POST /slack/events, a Slack Events API Request URL subscribed to app_mention
only — never message.channels — so RunLore reads nothing in a channel it was not addressed in. The
route exists on every deployment, but is a no-op unless opted in: with notify.slack.thread_capture
off (the default) it answers 404, exactly like /slack/interactions above when no feedback path
is configured. Once enabled, every request passes the identical check as the interactivity
callback, before any parsing-derived action: HMAC-SHA256 over the raw body against the app
signing secret, the same ±5-minute timestamp window against replay, constant-time compare, and a
1 MiB body cap. The Events API subscription handshake (Slack’s url_verification challenge) is
verified the same way, so an attacker cannot repoint your endpoint by guessing the Request URL. See
Expose only the paths you use above — /slack/events needs the same ingress treatment as
/slack/interactions, nothing more.
Matrix thread capture (notify.matrix.thread_capture) — a widened /sync filter
Opt-in and separate from the feedback channels above, same as Slack’s: replying @runlore note: …
inside an investigation thread lets a human write what they know straight into a knowledge-base PR —
reviewed and merged like any other curated entry, never applied automatically. It shares Matrix
feedback’s exposure story above (nothing inbound-reachable), but not its vote trust model, which is
why it gets its own section here too.
The /sync filter widens while this is on — stated plainly, not softened. With
notify.matrix.thread_capture off (the default), RunLore’s /sync filter requests only
["m.reaction"]: the process receives reactions in the configured room and nothing else. With
notify.matrix.thread_capture: true, the filter widens to ["m.reaction","m.room.message"] —
the process now receives message events from the configured room, where before it received only
reactions. That is what the option does the moment it is on; it is not a hypothetical worst case.
RunLore does not act on every message it receives. Two checks run before anything else happens: is
the message addressed to it (m.mentions, or the bot’s own MXID/localpart appearing in the body —
the same detection addressed() implements in the listener), and is it rooted, via the m.thread
relation or the m.in_reply_to fallback, in one of RunLore’s own investigation messages (the same
sender-is-the-bot trust anchor the feedback-reactions attribution check above uses). A message that
fails either check is dropped immediately, and its body is never logged. But — the honest part —
every message in the room does transit the process first: the homeserver delivers it over
/sync, and RunLore parses it far enough to run those two checks, before deciding to discard it. A
member’s ordinary chatter, and anything typed in a thread RunLore never started, reaches the process
and is read, even though nothing from it is ever stored or written anywhere.
The trade Matrix makes here is the transport’s genuine advantage over Slack. Enabling this needs
no exposed HTTP endpoint, no ingress change, and no new Kubernetes permission: the widened filter
rides the same outbound long-poll RunLore already runs for feedback_reactions, so there is nothing
new to route through your ingress or open in a NetworkPolicy. Weigh the two transports’ opposite
trade-offs before choosing between them: Slack’s thread
capture keeps its
inbound event stream limited to app_mention but adds an exposed, internet-reachable endpoint;
Matrix’s stays unexposed but widens what it reads from a room it already polls.
Operational requirement, same reasoning as matrix.feedback_reactions above: use an invite-only
room. With thread capture on, every room member’s messages reach the process (even though only an
addressed, correctly-rooted one is ever acted on), so an open room widens who can even attempt to be
heard by it.
Honest limitations
- The model sees cluster data. Even with redaction, tool output reaches your model provider. The strongest mitigation is self-hosting the model in-cluster. The redaction recall gaps above are real.
- RCA can be wrong. Frontier RCA is sub-50% on real incidents;
unresolvedis a first-class output and an adversarial verify pass can only lower confidence. Treat findings as hypotheses, and the human PR review as the load-bearing quality gate. - Prompt injection is bounded, not impossible. A poisoned alert or KB entry can bias an RCA, but it cannot trigger a write — the action gate ignores model-authored authorization fields, and recall is disabled under auto-execution so a poisoned catalog entry can’t short-circuit into an action.