An argN or any-arg guard on a kubectl grant would silently not bind, because umbra's valueFlags table is AWS-shaped #1351

Closed
opened 2026-08-28 18:47:06 +00:00 by coilyco-ops · 2 comments
Owner

Found while closing #1348 in #1349. Nothing is broken today. This is a trap laid for whoever writes the next guard, and it fails in the direction that does not announce itself.

The mechanic

umbra's positionals() strips flags before an argN or any-arg selector reads argv. To know that --context ser8 is two tokens rather than a flag followed by a positional, it consults a hardcoded table in cli/execverb/argv.go:

var valueFlags = map[string]bool{
	"--region": true, "--profile": true, "--output": true,
	"--endpoint-url": true, "--cli-read-timeout": true,
	"--cli-connect-timeout": true, "--color": true, "--ca-bundle": true,
	"--query": true,
}

Every entry is an AWS CLI global. No kubectl flag is in it - not --context, --namespace, -n, -f, -o.

So for aosguard ops kubectl get pods --context ser8, positionals() returns ["pods", "ser8"]. The flag's value is indistinguishable from a resource name.

Why it is quiet

when/deny-when on a flag selector are unaffected, because flagValue() scans argv for --flag value and --flag=value directly and never consults the table. That is why the guard in #1349 binds correctly, and I verified it against the built binary rather than assuming.

The table only governs argN and any-arg. There are no such guards on kubectl grants today, so there is no live defect. But the failure mode for the first person who adds one is:

  • they write deny-when arg0 matches kube-system intending to protect a namespace
  • it works in every test they run without --context
  • it silently stops meaning what they think the moment a caller passes --context something, because the argv slots shift

A guard that passes its own tests and then quietly stops binding is the exact shape of the #1348 defect this came out of, and of resolve_release_ref in #1346. Three in one incident.

umbra's own docs already name the hazard: "Dropping an entry weakens any argN guard on a binary taking that flag, and does it silently. umbra#282." So the engine knows, and the table is still one vendor's shape.

What to do

Upstream fix is umbra#282: the table belongs in the guardfile, declared per wrap, not hardcoded to one vendor.

Locally, until that lands, the cheap protection is a line in kubectl.kdl saying that argN and any-arg selectors are not safe on this wrap while kubectl's value flags are absent from umbra's table. A reader reaching for one deserves to be told before they write it, not after it stops binding.

Prefer a flag selector over argN on this wrap regardless. It reads what it names.

  • umbra#282 - the upstream table.
  • #1349 - where this was found. Its guard is a flag selector and is unaffected.
  • #1346, #1348 - the other two silent passes from the same incident.
Found while closing #1348 in #1349. Nothing is broken today. This is a trap laid for whoever writes the next guard, and it fails in the direction that does not announce itself. ## The mechanic umbra's `positionals()` strips flags before an `argN` or `any-arg` selector reads argv. To know that `--context ser8` is two tokens rather than a flag followed by a positional, it consults a hardcoded table in `cli/execverb/argv.go`: ```go var valueFlags = map[string]bool{ "--region": true, "--profile": true, "--output": true, "--endpoint-url": true, "--cli-read-timeout": true, "--cli-connect-timeout": true, "--color": true, "--ca-bundle": true, "--query": true, } ``` Every entry is an AWS CLI global. **No kubectl flag is in it** - not `--context`, `--namespace`, `-n`, `-f`, `-o`. So for `aosguard ops kubectl get pods --context ser8`, `positionals()` returns `["pods", "ser8"]`. The flag's *value* is indistinguishable from a resource name. ## Why it is quiet `when`/`deny-when` on a **flag selector** are unaffected, because `flagValue()` scans argv for `--flag value` and `--flag=value` directly and never consults the table. That is why the guard in #1349 binds correctly, and I verified it against the built binary rather than assuming. The table only governs `argN` and `any-arg`. There are no such guards on kubectl grants today, so there is no live defect. But the failure mode for the first person who adds one is: * they write `deny-when arg0 matches kube-system` intending to protect a namespace * it works in every test they run without `--context` * it silently stops meaning what they think the moment a caller passes `--context something`, because the argv slots shift A guard that passes its own tests and then quietly stops binding is the exact shape of the #1348 defect this came out of, and of `resolve_release_ref` in #1346. Three in one incident. umbra's own docs already name the hazard: *"Dropping an entry weakens any `argN` guard on a binary taking that flag, and does it silently. umbra#282."* So the engine knows, and the table is still one vendor's shape. ## What to do Upstream fix is umbra#282: the table belongs in the guardfile, declared per wrap, not hardcoded to one vendor. Locally, until that lands, the cheap protection is a line in `kubectl.kdl` saying that `argN` and `any-arg` selectors are not safe on this wrap while kubectl's value flags are absent from umbra's table. A reader reaching for one deserves to be told before they write it, not after it stops binding. Prefer a flag selector over `argN` on this wrap regardless. It reads what it names. ## Related * umbra#282 - the upstream table. * #1349 - where this was found. Its guard is a flag selector and is unaffected. * #1346, #1348 - the other two silent passes from the same incident.
Author
Owner

Read the code to fix this and stopped, because the fix has a fork that belongs to whoever owns umbra's grammar rather than to an unattended run. Re-labelling autonomy/async-consult. Everything below is measured at umbra main.

Confirmed, and it is worse than the issue says

cli/execverb/argv.go is 65 lines and the table is exactly as quoted. Two things to add:

The short-flag branch has the same defect and no table at all.

if strings.HasPrefix(tok, "-") && tok != "-" {
    continue // short flag
}

So -n kube-system drops -n and leaves kube-system in positionals. -n is the flag an author is most likely to reach for on kubectl, and it cannot be taught to the table because the table is long-flags-only.

The error path already exists. selectorValues returns ([]string, error) and evalWhen already fails closed on it, so a refusal needs no new plumbing above resolveSelector. Grant is already threaded to that point, so a declared flag set reaches it in two signature changes rather than four.

Why I did not just fix it

The obvious repair, teaching the table kubectl's flags, is the same trap re-armed for the next tool. The structural repair needs a declaration, and there the fork is real.

Truncating positionals at the first unknown flag fails open. I worked this through and discarded it: deny-when any-arg matches X would stop scanning before reaching X and allow the call. For a deny guard, reading fewer values is the dangerous direction.

So refusal is the only safe treatment of an ambiguous flag, which forces the question:

  • What does an undeclared flag mean? If unknown means refuse, then every boolean flag must also be declared or argN guards refuse ordinary calls like --all-namespaces. If unknown means boolean, the trap survives for exactly the flags nobody thought to declare.
  • Runtime or build time? Refusing at runtime protects the call. Refusing at guardfile-build time protects the author, is louder, and never surprises a live caller. Build time is the better fit for "a trap laid for whoever writes the next guard", but it needs a rule for the nine argN guards that already exist in aws.kdl, actions.kdl, and netlify.kdl, and aws only works today because the builtin table happens to be AWS-shaped.
  • Where does the declaration live? Wrap level fits, since value-taking is a property of the wrapped tool rather than of one grant, and it sits beside the existing per-grant allow-flag / deny-flag.

What I would build once that is settled

Wrap-level value-flag covering long and short forms, positionals consulting builtin plus declared, and resolveSelector returning an error so an unclassifiable flag refuses instead of guessing. Roughly: two signature changes, one grammar node, a validate() rule, tests for the kubectl and -n cases, and docs/execverb.md. It also needs an umbra release and a pin bump here, which is the loop #1385 just proved.

Not established

Whether the nine existing argN guards ever receive argv containing a flag. If they never do, a build-time refusal costs nothing and is clearly right. I did not check their call sites, and that check is most of the evidence the fork needs.

Read the code to fix this and stopped, because the fix has a fork that belongs to whoever owns umbra's grammar rather than to an unattended run. Re-labelling `autonomy/async-consult`. Everything below is measured at `umbra` main. ## Confirmed, and it is worse than the issue says `cli/execverb/argv.go` is 65 lines and the table is exactly as quoted. Two things to add: **The short-flag branch has the same defect and no table at all.** ```go if strings.HasPrefix(tok, "-") && tok != "-" { continue // short flag } ``` So `-n kube-system` drops `-n` and leaves `kube-system` in positionals. `-n` is the flag an author is most likely to reach for on kubectl, and it cannot be taught to the table because the table is long-flags-only. **The error path already exists.** `selectorValues` returns `([]string, error)` and `evalWhen` already fails closed on it, so a refusal needs no new plumbing above `resolveSelector`. `Grant` is already threaded to that point, so a declared flag set reaches it in two signature changes rather than four. ## Why I did not just fix it The obvious repair, teaching the table kubectl's flags, is the same trap re-armed for the next tool. The structural repair needs a declaration, and there the fork is real. **Truncating positionals at the first unknown flag fails open.** I worked this through and discarded it: `deny-when any-arg matches X` would stop scanning before reaching `X` and allow the call. For a deny guard, reading fewer values is the dangerous direction. So refusal is the only safe treatment of an ambiguous flag, which forces the question: * **What does an undeclared flag mean?** If unknown means refuse, then every boolean flag must also be declared or `argN` guards refuse ordinary calls like `--all-namespaces`. If unknown means boolean, the trap survives for exactly the flags nobody thought to declare. * **Runtime or build time?** Refusing at runtime protects the call. Refusing at guardfile-build time protects the author, is louder, and never surprises a live caller. Build time is the better fit for "a trap laid for whoever writes the next guard", but it needs a rule for the nine `argN` guards that already exist in `aws.kdl`, `actions.kdl`, and `netlify.kdl`, and aws only works today because the builtin table happens to be AWS-shaped. * **Where does the declaration live?** Wrap level fits, since value-taking is a property of the wrapped tool rather than of one grant, and it sits beside the existing per-grant `allow-flag` / `deny-flag`. ## What I would build once that is settled Wrap-level `value-flag` covering long and short forms, `positionals` consulting builtin plus declared, and `resolveSelector` returning an error so an unclassifiable flag refuses instead of guessing. Roughly: two signature changes, one grammar node, a `validate()` rule, tests for the kubectl and `-n` cases, and `docs/execverb.md`. It also needs an umbra release and a pin bump here, which is the loop `#1385` just proved. ## Not established Whether the nine existing `argN` guards ever receive argv containing a flag. If they never do, a build-time refusal costs nothing and is clearly right. I did not check their call sites, and that check is most of the evidence the fork needs.
Author
Owner

Fixed at the source. umbra PR #335 (coilyco-flight-deck/umbra), branch umbra/claude/ee98-valueflag.

Confirmed the mechanic first rather than trusting the write-up, and it is exactly as described:

positionals([get pods --context ser8])  ->  [get pods ser8]

ser8 sits in the positional list, so arg2 would read a flag value as a resource name.

What landed, in two halves

Either half alone still permits a silent bind, so both are needed.

  • value-flag <name> on a grant, merged over the built-in table. A guardfile now states its own tool's shape instead of inheriting AWS's. With it declared: positionals([...], "--context") -> [get pods].
  • A build-time refusal. A grant guarding argN/any-arg while allowing a long flag that neither the table nor its own list names is rejected when the guardfile parses, with a message naming the flag and both ways to satisfy it. The arity is not inferable from the flag alone, and guessing is what produced the silence in the first place.

Runtime behavior is unchanged for every guardfile that parses today, so this is not a migration.

A third test asserts a flag selector still needs no declaration, since flagValue() reads argv directly and was never affected. That keeps the fix from taxing the guards this issue correctly identified as fine.

docs/execverb.md had already called this: the table "is one vendor's shape and belongs in the guardfile" (umbra#282).

Not closable yet

This reaches aosguard only through the pin chain: umbra release, then ARG SPECGEN_VERSION in docker/dev-base/full/Dockerfile, then an aosguard release. That is the same chain that made #1366 look landed twice while the shipped binary was unchanged, so I am leaving this open until the built binary refuses a real undeclared grant. Nothing to do meanwhile - the trap is closed the moment the pin moves.

Fixed at the source. umbra PR #335 (`coilyco-flight-deck/umbra`), branch `umbra/claude/ee98-valueflag`. Confirmed the mechanic first rather than trusting the write-up, and it is exactly as described: ``` positionals([get pods --context ser8]) -> [get pods ser8] ``` `ser8` sits in the positional list, so `arg2` would read a flag value as a resource name. ## What landed, in two halves Either half alone still permits a silent bind, so both are needed. * **`value-flag <name>`** on a grant, merged over the built-in table. A guardfile now states its own tool's shape instead of inheriting AWS's. With it declared: `positionals([...], "--context") -> [get pods]`. * **A build-time refusal.** A grant guarding `argN`/`any-arg` while allowing a long flag that neither the table nor its own list names is rejected **when the guardfile parses**, with a message naming the flag and both ways to satisfy it. The arity is not inferable from the flag alone, and guessing is what produced the silence in the first place. Runtime behavior is unchanged for every guardfile that parses today, so this is not a migration. A third test asserts a **flag** selector still needs no declaration, since `flagValue()` reads argv directly and was never affected. That keeps the fix from taxing the guards this issue correctly identified as fine. `docs/execverb.md` had already called this: the table "is one vendor's shape and belongs in the guardfile" (umbra#282). ## Not closable yet This reaches aosguard only through the pin chain: umbra release, then `ARG SPECGEN_VERSION` in `docker/dev-base/full/Dockerfile`, then an aosguard release. That is the same chain that made #1366 look landed twice while the shipped binary was unchanged, so I am leaving this open until the built binary refuses a real undeclared grant. Nothing to do meanwhile - the trap is closed the moment the pin moves.
Sign in to join this conversation.
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#1351
No description provided.