execverb: add a sealed clause to block trailing caller args (enable strict single-resource verbs) #173

Closed
opened 2026-06-26 16:20:17 +00:00 by coilysiren · 4 comments
Owner

Goal

Make a can run grant able to forbid trailing caller args, so a pinned argv verb forwards exactly its fixed command and nothing else. Today every exec leaf appends the caller's args unconditionally, so even a fully-pinned argv can be widened by the caller. This blocks strictly-single-resource verbs. Motivating case: a ward-kdl verb that reads only the forgejo k8s secret and cannot be pivoted to any other secret.

Current behavior (verified in cli/execverb/)

actionFor (cli/execverb/execverb.go, ~line 240) builds the child argv as:

argv := append(append(append([]string{}, gf.ArgvPrefix...), g.ExecArgv()...), args...)

args is the caller's trailing tokens, always appended. Preflight checks run first but none can require "zero trailing args":

  • gateFunc gates run first, but gateRegistry is closed - only "aws-read" is registered (execverb.go ~line 209). No seam to add a custom or generic gate.
  • checkFlagPolicy (execverb.go ~290) governs flags only (allow/deny -flags), not positional trailing args.
  • checkWhens (deny-when / only pass / never pass) constrains arg values by glob, but cannot express "there must be no caller args at all."

Concrete failure: a grant can run read { argv get secret forgejo-runner-secrets -n forgejo -o "go-template={{.data.api-token | base64decode}}" } is widened by ... read othersecret -n kube-system - the extra tokens append and kubectl reaches another secret. The pin is not a seal.

Deliverable A (primary) - a sealed clause

Add a grant-level keyword sealed (rejected alternatives: seal, no-args, exact) parsed inside the can run block:

  • Parse: in cli/execverb/guardfile.go where Grant is parsed; add Sealed bool to the Grant struct (alongside Argv []string / ArgvSet bool, guardfile.go ~58-70).
  • Enforce: in actionFor (execverb.go), when g.Sealed is set, reject any non-empty args before the append, failing closed with a clear UserError (e.g. "sealed verb takes no caller arguments"). The forwarded argv is exactly ArgvPrefix + ExecArgv().
  • Validate fail-closed: sealed requires ArgvSet (a pinned argv). A sealed verb with no pinned argv is a parse error - a sealed bare subcommand is meaningless and must not silently mean "run the bare binary."
  • Tests (execverb_test.go): (1) sealed verb with no args runs the pinned argv; (2) sealed verb with ANY trailing arg is denied; (3) parse error when sealed without argv.

Example once shipped:

can run read {
    argv get secret forgejo-runner-secrets -n forgejo -o "go-template={{.data.api-token | base64decode}}"
    sealed
    describe "read ONLY the forgejo api-token from the k3s external-secrets mirror, decoded"
}

Deliverable B (optional, larger - scope separately if it grows)

Open the closed gateRegistry so guardfiles can declare richer named gates beyond aws-read - e.g. a generic gate exact-argv, or a host-registerable gate seam via Config. This is the general form of "custom gating"; sealed is the minimal special case of it. The missing "Config.Gates seam" is explicitly called out in the ward repo at cmd/ward-kdl/ward-kdl.brew.guardfile.kdl lines ~23-30 (the ward#92 / ward#95 decision that forced a hand-written-gated-Go workaround). Closing this seam retires that workaround class.

Acceptance

  • A can run grant with sealed + a pinned argv forwards exactly the pinned command; any caller arg is refused, with a test proving the pivot is blocked.
  • Parse-time fail-closed when sealed is set without argv.
  • docs/execverb.md (and the guardfile/passthrough docs) document sealed.
  • Downstream unblock noted: ward-kdl can then express a strictly-single-key forgejo secret read in pure KDL (no hand-written gated Go).

Files

cli/execverb/guardfile.go (Grant parse + struct), cli/execverb/execverb.go (actionFor sealed check, before the argv append ~line 248), cli/execverb/execverb_test.go, docs/execverb.md. Precedent for the workaround this removes: ward repo cmd/ward-kdl/ward-kdl.brew.guardfile.kdl lines ~23-30.

When done

Run the gate, commit, push to canonical main, update docs/FEATURES inventory if cli-guard keeps one, and comment on the dependent ward forgejo-key issue that the sealed clause is available.

## Goal Make a `can run` grant able to **forbid trailing caller args**, so a pinned `argv` verb forwards **exactly** its fixed command and nothing else. Today every exec leaf appends the caller's args unconditionally, so even a fully-pinned `argv` can be widened by the caller. This blocks strictly-single-resource verbs. Motivating case: a ward-kdl verb that reads **only** the forgejo k8s secret and cannot be pivoted to any other secret. ## Current behavior (verified in `cli/execverb/`) `actionFor` (cli/execverb/execverb.go, ~line 240) builds the child argv as: ```go argv := append(append(append([]string{}, gf.ArgvPrefix...), g.ExecArgv()...), args...) ``` `args` is the caller's trailing tokens, **always** appended. Preflight checks run first but **none can require "zero trailing args"**: - `gateFunc` gates run first, but `gateRegistry` is **closed** - only `"aws-read"` is registered (execverb.go ~line 209). No seam to add a custom or generic gate. - `checkFlagPolicy` (execverb.go ~290) governs **flags only** (allow/deny `-flags`), not positional trailing args. - `checkWhens` (`deny-when` / `only pass` / `never pass`) constrains arg **values** by glob, but cannot express "there must be no caller args at all." Concrete failure: a grant `can run read { argv get secret forgejo-runner-secrets -n forgejo -o "go-template={{.data.api-token | base64decode}}" }` is widened by `... read othersecret -n kube-system` - the extra tokens append and kubectl reaches another secret. The pin is not a seal. ## Deliverable A (primary) - a `sealed` clause Add a grant-level keyword **`sealed`** (rejected alternatives: `seal`, `no-args`, `exact`) parsed inside the `can run` block: - Parse: in `cli/execverb/guardfile.go` where `Grant` is parsed; add `Sealed bool` to the `Grant` struct (alongside `Argv []string` / `ArgvSet bool`, guardfile.go ~58-70). - Enforce: in `actionFor` (execverb.go), when `g.Sealed` is set, **reject any non-empty `args` before the append**, failing closed with a clear UserError (e.g. `"sealed verb takes no caller arguments"`). The forwarded argv is exactly `ArgvPrefix + ExecArgv()`. - Validate fail-closed: `sealed` **requires** `ArgvSet` (a pinned `argv`). A sealed verb with no pinned argv is a parse error - a sealed bare subcommand is meaningless and must not silently mean "run the bare binary." - Tests (execverb_test.go): (1) sealed verb with no args runs the pinned argv; (2) sealed verb with ANY trailing arg is denied; (3) parse error when `sealed` without `argv`. Example once shipped: ```kdl can run read { argv get secret forgejo-runner-secrets -n forgejo -o "go-template={{.data.api-token | base64decode}}" sealed describe "read ONLY the forgejo api-token from the k3s external-secrets mirror, decoded" } ``` ## Deliverable B (optional, larger - scope separately if it grows) Open the closed `gateRegistry` so guardfiles can declare richer **named** gates beyond `aws-read` - e.g. a generic `gate exact-argv`, or a host-registerable gate seam via `Config`. This is the general form of "custom gating"; `sealed` is the minimal special case of it. The missing "`Config.Gates` seam" is explicitly called out in the ward repo at `cmd/ward-kdl/ward-kdl.brew.guardfile.kdl` lines ~23-30 (the ward#92 / ward#95 decision that forced a hand-written-gated-Go workaround). Closing this seam retires that workaround class. ## Acceptance - A `can run` grant with `sealed` + a pinned `argv` forwards **exactly** the pinned command; any caller arg is refused, with a test proving the pivot is blocked. - Parse-time fail-closed when `sealed` is set without `argv`. - `docs/execverb.md` (and the guardfile/passthrough docs) document `sealed`. - Downstream unblock noted: ward-kdl can then express a strictly-single-key forgejo secret read in **pure KDL** (no hand-written gated Go). ## Files `cli/execverb/guardfile.go` (Grant parse + struct), `cli/execverb/execverb.go` (`actionFor` sealed check, before the argv append ~line 248), `cli/execverb/execverb_test.go`, `docs/execverb.md`. Precedent for the workaround this removes: ward repo `cmd/ward-kdl/ward-kdl.brew.guardfile.kdl` lines ~23-30. ## When done Run the gate, commit, push to canonical `main`, update docs/FEATURES inventory if cli-guard keeps one, and comment on the dependent ward forgejo-key issue that the `sealed` clause is available.
coilysiren changed title from execverb: add a seal clause to block trailing caller args (enable strict single-resource verbs) to execverb: add a sealed clause to block trailing caller args (enable strict single-resource verbs) 2026-06-26 17:45:43 +00:00
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-cli-guard-173 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-07-01T04:43:37Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-cli-guard-173` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-07-01T04:43:37Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - sealed clause landed on main: an argv grant can now forbid trailing caller args, forwarding its pinned command exactly.

The core of this one was genuinely a one-liner in actionFor plus a struct field and a parse-time check - the deny-by-structure engine was already shaped so sealed slotted in beside the existing argv override with no friction. The sealed-requires-argv invariant fell out naturally next to the existing wildcard-forbids-argv guard, which was a nice sign the grammar was pointing the right way.

What actually fought back was the docs, not the code. docs/execverb.md was sitting at 3982 chars against a hard 4000-char pre-commit cap, so adding one honest grammar bullet blew the budget by ~600 chars and I spent more effort trimming redundant prose across unrelated bullets than writing the feature. It's under cap now, but that doc is one keyword away from needing a split (an argv/sealed sub-doc would be the clean move) - worth a follow-up before the next grammar addition. Merge to main also hit a conflict with the concurrent specgen -> kdl-specs rename, resolved cleanly.

Confidence is high: three targeted tests cover the three required behaviors (pinned-argv forwards, trailing arg refused, parse error without argv), plus a describe-surface test, and the full suite is green (the only failures are the pre-existing sandbox seccomp/ptrace tests that need privileges this container lacks). Deliverable B (opening gateRegistry for custom named gates) was optional and I left it out to keep the change tight - worth its own issue if a second consumer wants a custom gate.

WARD-OUTCOME: done - `sealed` clause landed on main: an `argv` grant can now forbid trailing caller args, forwarding its pinned command exactly. The core of this one was genuinely a one-liner in `actionFor` plus a struct field and a parse-time check - the deny-by-structure engine was already shaped so `sealed` slotted in beside the existing `argv` override with no friction. The `sealed`-requires-`argv` invariant fell out naturally next to the existing `wildcard`-forbids-`argv` guard, which was a nice sign the grammar was pointing the right way. What actually fought back was the docs, not the code. `docs/execverb.md` was sitting at 3982 chars against a hard 4000-char pre-commit cap, so adding one honest grammar bullet blew the budget by ~600 chars and I spent more effort trimming redundant prose across unrelated bullets than writing the feature. It's under cap now, but that doc is one keyword away from needing a split (an `argv`/`sealed` sub-doc would be the clean move) - worth a follow-up before the next grammar addition. Merge to main also hit a conflict with the concurrent `specgen -> kdl-specs` rename, resolved cleanly. Confidence is high: three targeted tests cover the three required behaviors (pinned-argv forwards, trailing arg refused, parse error without argv), plus a describe-surface test, and the full suite is green (the only failures are the pre-existing sandbox seccomp/ptrace tests that need privileges this container lacks). Deliverable B (opening `gateRegistry` for custom named gates) was optional and I left it out to keep the change tight - worth its own issue if a second consumer wants a custom gate.
Author
Owner

x

x
Author
Owner

WARD-OUTCOME: done - sealed clause landed on main; the retrospective is two comments up.

Housekeeping: the bare x comment just above was an accidental artifact from a verification command, and comment deletion is denied by policy so I can't remove it. The real retrospective and status line are the earlier WARD-OUTCOME: done comment. Nothing about the code or the merge changed.

WARD-OUTCOME: done - `sealed` clause landed on main; the retrospective is two comments up. Housekeeping: the bare `x` comment just above was an accidental artifact from a verification command, and comment deletion is denied by policy so I can't remove it. The real retrospective and status line are the earlier `WARD-OUTCOME: done` comment. Nothing about the code or the merge changed.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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/cli-guard#173
No description provided.