feat(execverb): allow inspect-list passthrough sugar (N read-only binaries per wrap) #157

Closed
opened 2026-06-19 07:10:46 +00:00 by coilyco-ops · 2 comments
Member

Summary

Add an allow <bin...> node to the execverb passthrough dialect: a single wrap block that opens a read-only passthrough funnel for each binary in a flat list, mounting each as a sibling subcommand. This is the "inspect tier" of the passthrough concept — the mega-list of read-only OS primitives (ls grep tail cat jq ps df ...) that ward should always let through without a hand-written verb.

It complements the host tier already shipped on feat/passthrough-ssh-dialect: passthrough <bin> wraps one mutating primitive whole (systemctl, launchctl), one binary per file. Inspect is the same idea for read-only binaries, but collapsed into one flat list so we don't carry 30 near-identical single-binary wrap files.

Tiering (the organizing principle, decided in discussion):

  • inspect — read-only no matter the args (grep, cat, ps, df, jq). One mega-file, one allow line, grows freely, never reviewed for safety.
  • host — mutates, allowed as an OS primitive (systemctl, launchctl, mkdir, chmod). One passthrough file per binary, reviewed when it grows.
  • out of passthrough entirely — mixed read/write surface (git, kubectl, aws) stays a declared verb. A binary only enters passthrough if its whole surface fits one tier; that rule is what lets allow stay a dumb, unreviewed flat list.

Why a new keyword (not overloaded passthrough)

passthrough <bin> [prefix...] already assigns trailing tokens to the fixed argv prefix (passthrough tailscale ssh execs tailscale ssh <args...>). So passthrough ls grep would mean "exec ls with a fixed grep prefix", not "two binaries". The list form must therefore be its own node. Keyword: allow (verb of admission; no quantifier ambiguity like every/all; distinct from the existing grant-body allow-flag, which is a different scope — call this out in the doc to avoid reader confusion).

Grammar

// cmd/ward-kdl/ward-kdl.inspect.guardfile.kdl  (the consumer, lands in ward — see Phase 2)
wrap ward-kdl {
    allow ls cat head tail wc grep find stat jq yq \
          ps df du free uptime who id env which uname hostname dig host
}
  • allow <bin...> — wrap-body node, exec/passthrough dialect. For each <bin>, mount a leaf equivalent to a nested wrap ward-kdl <bin> { passthrough <bin> }: exec that binary, implicit can run "*" funnel, mounted as the sibling subcommand ward-kdl <bin>. Names are bare binaries only — no fixed argv prefix (that is what single passthrough is for); reject a name containing a path separator or shell metachar, fail-closed.
  • Mutually exclusive with exec, passthrough, and can run in the same wrap. A wrap is single-binary or an allow-list, never both. Emit the same fail-closed error style as the existing exec/passthrough exclusivity at cli/execverb/guardfile.go:141-143.
  • Empty list is an error (allow with no args mounts nothing — fail closed, mirror guardfile.go:131-133).
  • Wrap-level guards still apply. never pass <token> and only pass when <selector> is <glob> (guardfile.go:157-165) attach to every generated leaf, same as they do for a single passthrough. Inspect won't usually need a host gate (read-only), but the composition must hold so e.g. a never pass speed-bump can scope the whole list.
  • wrap ward-kdl with no subpath. The wrap arg list is just ["ward-kdl"] (the binary/group); the allow names are the leaves. The parser currently requires a single Bin — see Implementation.

Mount / runtime semantics

Each allowed binary is an independent open-passthrough surface (can run "*" funnel) — identical engine path to a single passthrough <bin>: actionFor enforces wrap-level guards then execs (cli/execverb/execverb.go:197-208). Auditing, the sandbox jail, and grandchild re-entry are inherited unchanged from the wildcard funnel — allow adds no new engine behavior, only a fan-out over names.

Implementation

Repo: forgejo.coilysiren.me/coilyco-flight-deck/cli-guard, branch off feat/passthrough-ssh-dialect (where the passthrough dialect lives; main does not have it yet).

The crux: Parse returns one *Guardfile with a single scalar Bin (cli/execverb/guardfile.go:13-24, :128-130). allow is N binaries from one wrap, so the single-Bin assumption has to flex on this path. Two viable shapes, implementer's choice:

  1. Synthesize sibling Guardfilesallow parses into a slice the driver expands into one passthrough *Guardfile per name (Group ["ward-kdl", <bin>], Bin=<bin>, passthrough=true, sharing the wrap's Whens). Cleanest: reuses the existing single-binary mount path verbatim, the fan-out is purely a parse-time desugar.
  2. Per-leaf bin at mount — add an Allow []string field, and have mountWildcard (cli/execverb/execverb.go:88-127) loop, building one wildcard leaf per name with its own bin. Requires threading a per-leaf bin where Bin is read today.

Prefer (1) — it keeps the engine's single-Bin invariant and makes allow a pure front-end desugar over the already-tested passthrough path.

Touch points (branch feat/passthrough-ssh-dialect):

  • cli/execverb/guardfile.go — add case "allow": to applyNode (:138-168), a parseAllow that validates bare-binary names and exclusivity. Relax the Bin == "" check (:128-130) for the allow path (it has no single Bin), or set up the desugar so each synthesized member still satisfies it.
  • cli/execverb/execverb.go — if going with shape (1), the desugar likely lands where members are built (driver side, http/specdrv/specdrv.go:83-112 reads members); if shape (2), extend mountWildcard.
  • cli/execverb/describe.go — render each allow leaf in the describe surface (:122 open-passthrough rendering already exists; fan it out per name).
  • docs/passthrough.md — add an ## Inspect lists (allow) section: grammar, the bare-binary constraint, the why-not-overload-passthrough reasoning, and the allow vs allow-flag scope distinction.

Tests (cli/execverb/*_test.go)

  • Parse: allow a b c → three passthrough leaves named a/b/c, each Wildcard/funnel.
  • Parse rejects: allow + exec in one wrap; allow + can run; empty allow; a name with / or a shell metachar.
  • Mount: ward-kdl ls, ward-kdl grep are each callable and exec the right binary; args pass through.
  • Guards compose: a wrap-level never pass rm and only pass when shell hostname is ... apply to every allow leaf (reuse the host-gate test harness with the injectable HostResolver, execverb.go:25-27).
  • Golden/describe: the describe surface lists all allow leaves.

Done condition

allow <bin...> parses, mounts one funnel per name, composes with wrap-level guards, rejects the exclusivity/empty/bad-name cases, is documented in docs/passthrough.md, and is covered by the tests above. Then cut a cli-guard release tag so ward can bump its dep (Phase 2).


Phase 2 (separate ward issue — blocked on the cli-guard release above)

Filed against coilyco-flight-deck/infrastructure's consumer is actually coilyco-flight-deck/ward (cmd/ward-kdl/). Not part of this issue; outline so nothing is lost:

  1. Bump cli-guard dep in ward/go.mod to the release carrying allow.
  2. Author cmd/ward-kdl/ward-kdl.inspect.guardfile.kdl — the mega allow list (read-only set above; seed from the infrastructure script audit: cat grep find stat jq ps df ...).
  3. Author per-host files, one passthrough per binary (Kai's call: own file each, ~3 lines): systemctl, launchctl, journalctl, service, and the mutating coreutils kept as host primitives (mkdir mv cp chmod chown ln touch install). Discovery is automatic — the *.guardfile.kdl glob (http/specdrv/specdrv.go:127) merges them into the ward-kdl binary by Group[0]; no registry edit.
  4. make build-ward-kdl to regen and move reference docs. Decide docs shape up front: one docs/ward-kdl.passthrough.md covering the convention + the inspect file, rather than a 3-line doc stub per host binary.
  5. Wire the lockdown/deny-list so the bare binaries route through the ward-kdl funnel instead of being hard-denied.
  6. Update ward/docs/FEATURES.md.

Context

Discussion origin: ward-kdl passthrough design, inspect-vs-host tiering. Host-tier grammar shipped on cli-guard feat/passthrough-ssh-dialect (a476b69), designed in cli-guard#130 / #123. The untracked ward ssh/tailscale-ssh guardfiles already consume the host tier; this issue adds the inspect tier they're missing.

## Summary Add an `allow <bin...>` node to the execverb **passthrough dialect**: a single `wrap` block that opens a read-only passthrough funnel for each binary in a flat list, mounting each as a sibling subcommand. This is the "inspect tier" of the passthrough concept — the mega-list of read-only OS primitives (`ls grep tail cat jq ps df ...`) that ward should always let through without a hand-written verb. It complements the **host tier** already shipped on `feat/passthrough-ssh-dialect`: `passthrough <bin>` wraps one mutating primitive whole (`systemctl`, `launchctl`), one binary per file. Inspect is the same idea for read-only binaries, but collapsed into one flat list so we don't carry 30 near-identical single-binary wrap files. Tiering (the organizing principle, decided in discussion): - **inspect** — read-only *no matter the args* (`grep`, `cat`, `ps`, `df`, `jq`). One mega-file, one `allow` line, grows freely, never reviewed for safety. - **host** — mutates, allowed as an OS primitive (`systemctl`, `launchctl`, `mkdir`, `chmod`). One `passthrough` file per binary, reviewed when it grows. - **out of passthrough entirely** — mixed read/write surface (`git`, `kubectl`, `aws`) stays a declared verb. A binary only enters passthrough if its *whole* surface fits one tier; that rule is what lets `allow` stay a dumb, unreviewed flat list. ## Why a new keyword (not overloaded `passthrough`) `passthrough <bin> [prefix...]` already assigns trailing tokens to the **fixed argv prefix** (`passthrough tailscale ssh` execs `tailscale ssh <args...>`). So `passthrough ls grep` would mean "exec `ls` with a fixed `grep` prefix", not "two binaries". The list form must therefore be its own node. Keyword: **`allow`** (verb of admission; no quantifier ambiguity like `every`/`all`; distinct from the existing grant-body `allow-flag`, which is a different scope — call this out in the doc to avoid reader confusion). ## Grammar ```kdl // cmd/ward-kdl/ward-kdl.inspect.guardfile.kdl (the consumer, lands in ward — see Phase 2) wrap ward-kdl { allow ls cat head tail wc grep find stat jq yq \ ps df du free uptime who id env which uname hostname dig host } ``` - **`allow <bin...>`** — wrap-body node, exec/passthrough dialect. For each `<bin>`, mount a leaf equivalent to a nested `wrap ward-kdl <bin> { passthrough <bin> }`: exec that binary, implicit `can run "*"` funnel, mounted as the sibling subcommand `ward-kdl <bin>`. Names are **bare binaries only** — no fixed argv prefix (that is what single `passthrough` is for); reject a name containing a path separator or shell metachar, fail-closed. - **Mutually exclusive** with `exec`, `passthrough`, and `can run` in the same wrap. A wrap is single-binary *or* an allow-list, never both. Emit the same fail-closed error style as the existing `exec`/`passthrough` exclusivity at `cli/execverb/guardfile.go:141-143`. - **Empty list is an error** (`allow` with no args mounts nothing — fail closed, mirror `guardfile.go:131-133`). - **Wrap-level guards still apply.** `never pass <token>` and `only pass when <selector> is <glob>` (`guardfile.go:157-165`) attach to *every* generated leaf, same as they do for a single `passthrough`. Inspect won't usually need a host gate (read-only), but the composition must hold so e.g. a `never pass` speed-bump can scope the whole list. - **`wrap ward-kdl` with no subpath.** The wrap arg list is just `["ward-kdl"]` (the binary/group); the `allow` names are the leaves. The parser currently requires a single `Bin` — see Implementation. ## Mount / runtime semantics Each allowed binary is an independent open-passthrough surface (`can run "*"` funnel) — identical engine path to a single `passthrough <bin>`: `actionFor` enforces wrap-level guards then execs (`cli/execverb/execverb.go:197-208`). Auditing, the sandbox jail, and grandchild re-entry are inherited unchanged from the wildcard funnel — `allow` adds no new engine behavior, only a fan-out over names. ## Implementation Repo: `forgejo.coilysiren.me/coilyco-flight-deck/cli-guard`, branch off `feat/passthrough-ssh-dialect` (where the passthrough dialect lives; `main` does not have it yet). The crux: `Parse` returns one `*Guardfile` with a single scalar `Bin` (`cli/execverb/guardfile.go:13-24`, `:128-130`). `allow` is N binaries from one wrap, so the single-`Bin` assumption has to flex on this path. Two viable shapes, implementer's choice: 1. **Synthesize sibling Guardfiles** — `allow` parses into a slice the driver expands into one passthrough `*Guardfile` per name (Group `["ward-kdl", <bin>]`, `Bin=<bin>`, `passthrough=true`, sharing the wrap's `Whens`). Cleanest: reuses the existing single-binary mount path verbatim, the fan-out is purely a parse-time desugar. 2. **Per-leaf bin at mount** — add an `Allow []string` field, and have `mountWildcard` (`cli/execverb/execverb.go:88-127`) loop, building one wildcard leaf per name with its own bin. Requires threading a per-leaf bin where `Bin` is read today. Prefer (1) — it keeps the engine's single-Bin invariant and makes `allow` a pure front-end desugar over the already-tested passthrough path. Touch points (branch `feat/passthrough-ssh-dialect`): - `cli/execverb/guardfile.go` — add `case "allow":` to `applyNode` (`:138-168`), a `parseAllow` that validates bare-binary names and exclusivity. Relax the `Bin == ""` check (`:128-130`) for the allow path (it has no single Bin), or set up the desugar so each synthesized member still satisfies it. - `cli/execverb/execverb.go` — if going with shape (1), the desugar likely lands where members are built (driver side, `http/specdrv/specdrv.go:83-112` reads members); if shape (2), extend `mountWildcard`. - `cli/execverb/describe.go` — render each allow leaf in the describe surface (`:122` open-passthrough rendering already exists; fan it out per name). - `docs/passthrough.md` — add an `## Inspect lists (`allow`)` section: grammar, the bare-binary constraint, the why-not-overload-`passthrough` reasoning, and the `allow` vs `allow-flag` scope distinction. ## Tests (cli/execverb/*_test.go) - Parse: `allow a b c` → three passthrough leaves named a/b/c, each `Wildcard`/funnel. - Parse rejects: `allow` + `exec` in one wrap; `allow` + `can run`; empty `allow`; a name with `/` or a shell metachar. - Mount: `ward-kdl ls`, `ward-kdl grep` are each callable and exec the right binary; args pass through. - Guards compose: a wrap-level `never pass rm` and `only pass when shell hostname is ...` apply to every allow leaf (reuse the host-gate test harness with the injectable `HostResolver`, `execverb.go:25-27`). - Golden/describe: the describe surface lists all allow leaves. ## Done condition `allow <bin...>` parses, mounts one funnel per name, composes with wrap-level guards, rejects the exclusivity/empty/bad-name cases, is documented in `docs/passthrough.md`, and is covered by the tests above. Then cut a cli-guard release tag so ward can bump its dep (Phase 2). --- ## Phase 2 (separate ward issue — blocked on the cli-guard release above) Filed against `coilyco-flight-deck/infrastructure`'s consumer is actually `coilyco-flight-deck/ward` (`cmd/ward-kdl/`). Not part of this issue; outline so nothing is lost: 1. Bump cli-guard dep in `ward/go.mod` to the release carrying `allow`. 2. Author `cmd/ward-kdl/ward-kdl.inspect.guardfile.kdl` — the mega `allow` list (read-only set above; seed from the infrastructure script audit: `cat grep find stat jq ps df ...`). 3. Author per-host files, one `passthrough` per binary (Kai's call: own file each, ~3 lines): `systemctl`, `launchctl`, `journalctl`, `service`, and the mutating coreutils kept as host primitives (`mkdir mv cp chmod chown ln touch install`). Discovery is automatic — the `*.guardfile.kdl` glob (`http/specdrv/specdrv.go:127`) merges them into the `ward-kdl` binary by `Group[0]`; no registry edit. 4. `make build-ward-kdl` to regen and move reference docs. Decide docs shape up front: one `docs/ward-kdl.passthrough.md` covering the convention + the inspect file, rather than a 3-line doc stub per host binary. 5. Wire the lockdown/deny-list so the bare binaries route through the ward-kdl funnel instead of being hard-denied. 6. Update `ward/docs/FEATURES.md`. ## Context Discussion origin: ward-kdl passthrough design, inspect-vs-host tiering. Host-tier grammar shipped on cli-guard `feat/passthrough-ssh-dialect` (`a476b69`), designed in cli-guard#130 / #123. The untracked ward `ssh`/`tailscale-ssh` guardfiles already consume the host tier; this issue adds the inspect tier they're missing.
Author
Member

🔒 Reserved by ward agent claude — container ward-cli-guard-issue-157-claude-4c18e3b8 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-06-19T07:18:45Z). 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 claude` — container `ward-cli-guard-issue-157-claude-4c18e3b8` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-06-19T07:18:45Z). 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
Member

🔒 Reserved by ward agent claude — container ward-cli-guard-issue-157-claude-567a60d6 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-06-24T04:36:21Z). 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 claude` — container `ward-cli-guard-issue-157-claude-567a60d6` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-06-24T04:36:21Z). 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`
Sign in to join this conversation.
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/cli-guard#157
No description provided.