fix(aosguard): make kubectl name its cluster, closing a boundary that never bound #1349

Merged
coilyco-ops merged 3 commits from aos/claude/mt75-kubectl-context into main 2026-08-28 18:53:49 +00:00
Owner

Closes #1348. Kai's call on the shape, made after two rounds of corrected premises.

The boundary did not bind

The kubectl guardfile header claimed "the exec target is fixed at parse, so the caller can never substitute it." False as deployed. exec fixes the binary, not the cluster, and nothing constrained --context:

aosguard ops kubectl get nodes                 -> kai-server  v1.32.3+k3s1
aosguard ops kubectl get nodes --context ser8  -> ser8        v1.35.5+k3s1

Different node, different server version. aosguard did no flag filtering on this wrap at all: --definitely-not-a-real-flag came back as error: unknown flag from kubectl, not from the guard.

Scoped honestly, per Vera's refinement: a typo already failed closed, because the reachable set is exactly the kubeconfig's contexts. The real exposure was a valid but wrong context accepted on apply, scale, and rollout while the comment told a reader that was impossible - which made a stray --context look inert when it was not.

Why wrap-level, and why mandatory

One only pass when context is kai-server ser8 at wrap level. execverb.go:300 checks wrap-level Whens on every leaf, so a grant added later cannot forget it. Twenty per-grant copies would each be a place to omit it.

Mandatory rather than checked-when-present is forced by the dialect, not a preference. resolveSelector returns no values for an absent flag, firstMatch then fails, and a non-deny when refuses fail-closed. Confirmed on an unrelated wrap:

aosguard ops aws ssm put-parameter
-> denied: name did not match any allowed pattern [/*] (fail-closed)

So an optional allowlist is not expressible in umbra today. Two shapes were rejected for concrete reasons: allow-flag is a strict allowlist (docs/execverb.md), so adding allow-flag --context to apply would deny -f unless every legitimate flag were enumerated per grant; and argv-prefix pins leading argv but caller args land after it, where a later --context wins.

Verified against the built binary

Behavior, not dialect-reading - this file's last claim was wrong precisely because nobody ran it:

call result
no --context denied, fail-closed
--context ser8 allowed, reaches ser8
--context kai-server allowed, reaches kai-server
--context prod-secret denied at the guard, not at kubectl
apply with no --context denied before exec

The unknown-context deny moving from kubectl into the guard is the point: the refusal now happens in the control.

Known cost

config get-contexts now needs a context, which reads circular. The deny message names both valid values, so the error is self-documenting. Flagged rather than carved out, since an exemption in a security control is not mine to add quietly.

Blast radius

forgejo_storage_measure.py calls the kubectl binary through its own wrap, not through aosguard ops kubectl, so it is unaffected. It does run against the active context with no --context, which is the same ambiguity one layer over - noted, not touched here.

The composed Kubernetes skill routed agents to this wrap without the flag, so it would have taught a call that now denies, and it still described the estate as single-cluster. Both corrected.

Verification

just aosguard-build materializes cleanly, uv run pytest 753 passed, pre-commit run --all-files green.

typos splits the alpha run in ser8, so the allowlist key has to be ser; ser8 = "ser8" silently never matches. Noted in .typos.toml so it is not "fixed" back.

Worth a sweep

If one guardfile documented an invariant it did not enforce, others may too. Vera has that in #1348's acceptance criteria. Not attempted here.

Closes #1348. Kai's call on the shape, made after two rounds of corrected premises. ## The boundary did not bind The kubectl guardfile header claimed *"the exec target is fixed at parse, so the caller can never substitute it."* False as deployed. `exec` fixes the **binary**, not the cluster, and nothing constrained `--context`: ``` aosguard ops kubectl get nodes -> kai-server v1.32.3+k3s1 aosguard ops kubectl get nodes --context ser8 -> ser8 v1.35.5+k3s1 ``` Different node, different server version. aosguard did no flag filtering on this wrap at all: `--definitely-not-a-real-flag` came back as `error: unknown flag` from *kubectl*, not from the guard. **Scoped honestly**, per Vera's refinement: a typo already failed closed, because the reachable set is exactly the kubeconfig's contexts. The real exposure was a **valid but wrong** context accepted on `apply`, `scale`, and `rollout` while the comment told a reader that was impossible - which made a stray `--context` look inert when it was not. ## Why wrap-level, and why mandatory One `only pass when context is kai-server ser8` at wrap level. `execverb.go:300` checks wrap-level `Whens` on every leaf, so a grant added later cannot forget it. Twenty per-grant copies would each be a place to omit it. Mandatory rather than checked-when-present is **forced by the dialect**, not a preference. `resolveSelector` returns no values for an absent flag, `firstMatch` then fails, and a non-deny `when` refuses fail-closed. Confirmed on an unrelated wrap: ``` aosguard ops aws ssm put-parameter -> denied: name did not match any allowed pattern [/*] (fail-closed) ``` So an *optional* allowlist is not expressible in umbra today. Two shapes were rejected for concrete reasons: `allow-flag` is a strict allowlist (`docs/execverb.md`), so adding `allow-flag --context` to `apply` would deny `-f` unless every legitimate flag were enumerated per grant; and `argv-prefix` pins leading argv but caller args land after it, where a later `--context` wins. ## Verified against the built binary Behavior, not dialect-reading - this file's last claim was wrong precisely because nobody ran it: | call | result | |---|---| | no `--context` | denied, fail-closed | | `--context ser8` | allowed, reaches ser8 | | `--context kai-server` | allowed, reaches kai-server | | `--context prod-secret` | denied **at the guard**, not at kubectl | | `apply` with no `--context` | denied before exec | The unknown-context deny moving from kubectl into the guard is the point: the refusal now happens in the control. ## Known cost `config get-contexts` now needs a context, which reads circular. The deny message names both valid values, so the error is self-documenting. Flagged rather than carved out, since an exemption in a security control is not mine to add quietly. ## Blast radius `forgejo_storage_measure.py` calls the `kubectl` binary through its own wrap, not through `aosguard ops kubectl`, so it is unaffected. It does run against the active context with no `--context`, which is the same ambiguity one layer over - noted, not touched here. The composed Kubernetes skill routed agents to this wrap without the flag, so it would have taught a call that now denies, and it still described the estate as single-cluster. Both corrected. ## Verification `just aosguard-build` materializes cleanly, `uv run pytest` 753 passed, `pre-commit run --all-files` green. `typos` splits the alpha run in `ser8`, so the allowlist key has to be `ser`; `ser8 = "ser8"` silently never matches. Noted in `.typos.toml` so it is not "fixed" back. ## Worth a sweep If one guardfile documented an invariant it did not enforce, others may too. Vera has that in #1348's acceptance criteria. Not attempted here.
fix(aosguard): make kubectl name its cluster, closing a boundary that never bound
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 7s
ci / ward-doctor (pull_request) Successful in 7s
ci / aos-cli-tests (pull_request) Successful in 32s
ci / gate (pull_request) Successful in 54s
e39b29d89c
The kubectl guardfile header claimed "the exec target is fixed at parse, so
the caller can never substitute it". That was false as deployed. `exec`
fixes the binary, not the cluster, and nothing constrained `--context`:

  aosguard ops kubectl get nodes                 -> kai-server  v1.32.3+k3s1
  aosguard ops kubectl get nodes --context ser8  -> ser8        v1.35.5+k3s1

A declared invariant that does not bind. The live exposure was a valid but
wrong context accepted on apply, scale, and rollout, while the comment told
the reader that could not happen. A typo already failed closed, since the
reachable set is the kubeconfig's own contexts, so this is narrower than a
write to anywhere.

One wrap-level `only pass` now names the cluster on every leaf. It is
wrap-level rather than per-grant so a later grant cannot forget it, and it
is mandatory rather than checked-when-present because umbra resolves an
absent selector to no values, which fails a non-deny guard closed. That
same mechanic is why an optional allowlist is not expressible here.

Verified against the built binary rather than by reading the dialect:

  no --context            -> denied (fail-closed)
  --context ser8          -> allowed, reaches ser8
  --context kai-server    -> allowed, reaches kai-server
  --context prod-secret   -> denied at the guard, not at kubectl
  apply with no --context -> denied before exec

`config get-contexts` now needs a context too, which reads circular. The
deny names both valid values, so the error is self-documenting.

The composed Kubernetes skill routed agents here without the flag, so it
would have taught a call that now denies. It also still called the estate
single-cluster. Both corrected.

typos splits the alpha run in `ser8`, so the allowlist key has to be `ser`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
Merge branch 'main' into aos/claude/mt75-kubectl-context
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 8s
ci / aos-cli-tests (pull_request) Successful in 42s
ci / gate (pull_request) Successful in 58s
377d8c9c6f
Merge branch 'main' into aos/claude/mt75-kubectl-context
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 9s
ci / aos-cli-tests (pull_request) Successful in 46s
ci / gate (pull_request) Successful in 1m5s
0cb338ff5e
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os!1349
No description provided.