Reorganize package tree around the three guarded surfaces (CLI / HTTP / MCP) + shared pkg #113

Closed
opened 2026-06-07 22:40:10 +00:00 by coilysiren · 1 comment
Owner

Summary

cli-guard has grown ~40 flat top-level packages that each accreted around one of
three guarded surfaces plus a shared core. The flat layout no longer signals
which package belongs to which surface, and the surface boundaries are now the
real architectural seams. Propose reorganizing the tree around them.

The framing that crystallized this:

  • CLI passthrough - the original reason cli-guard exists: sit between an
    agent and an existing binary, audit and validate every argv before execve.
  • HTTP requests - the natural extension once we hit a load-bearing platform
    with no first-class CLI (Forgejo). Guard the request, not the subprocess.
  • MCP - the obvious next surface once you think about the ways agents cause
    chaos through tool servers rather than shells.
  • pkg - everything shared across the three: audit, policy, scope, the
    config/cache plumbing.

Why now

  • The four buckets are independently coherent. A reader looking for "how do we
    guard an HTTP call" should land in one subtree, not grep 40 dirs.
  • New surfaces (HTTP, MCP) were bolted onto a layout designed for one surface
    (CLI). The seams are now load-bearing enough to make explicit in the package
    tree.
  • Import direction becomes enforceable: surfaces depend on pkg, never on each
    other. Today nothing stops egress importing passthrough.

Proposed bucketing (starting point, not final)

CLI passthrough surface
passthrough, verb, repocfg, allowlist, catalog, gittree, hook,
hookcfg, shim, doctor, sudo, shell, dispatch, lockdown,
profiles, profile, decision

HTTP surface
egress, specverb, guardfile, specgen, specdrv, respfmt,
ghcache, ghidcache, ghratelimit, stscache

MCP surface
mcporter

pkg (shared core)
audit, policy, scope, exitcode, config, ttlcache, workdir,
skillgen

cmd/, docs/, examples/, scripts/ stay where they are.

Open questions

  • Nesting depth: surface/http/egress vs. a flat http/? Go discourages deep
    nesting but the surface prefix is the point. Lean shallow: cli/, http/,
    mcp/, pkg/.
  • Where do the *cache packages live - under pkg (generic) or under the
    surface that drives them (gh* under http, stscache under... CLI? both)?
    Probably pkg/cache for the generic TTL machinery, surface dirs for the
    domain-specific caches.
  • decision / profiles / lockdown are cross-surface (they gate both CLI and
    potentially HTTP/MCP). May belong in pkg, not the CLI bucket.
  • Import-path churn: this renames every package path. Module is
    forgejo.coilysiren.me/coilyco-flight-deck/cli-guard/<pkg>, so every consumer
    (ward, coily) re-imports. Needs a single atomic rename commit + a coordinated
    consumer bump off the resulting tag. cli-guard is the base of the triple, so
    sequencing matters.
  • Whether to add a surfaces.md / update docs/FEATURES.md to group the
    inventory by surface in the same pass.

Scope of work

  1. Agree the four buckets and the package-to-bucket assignment (this issue).
  2. One atomic move/rename commit (git mv + import rewrites, goimports).
  3. Re-render docs/FEATURES.md grouped by surface; add a short
    docs/architecture.md naming the surfaces and the pkg-only-downward import
    rule.
  4. Coordinated consumer bump (ward, coily) off the new tag.

Filed from a design conversation - the bucketing above is a proposal to react
to, not a settled plan.

## Summary cli-guard has grown ~40 flat top-level packages that each accreted around one of **three guarded surfaces** plus a shared core. The flat layout no longer signals which package belongs to which surface, and the surface boundaries are now the real architectural seams. Propose reorganizing the tree around them. The framing that crystallized this: - **CLI passthrough** - the original reason cli-guard exists: sit between an agent and an existing binary, audit and validate every argv before `execve`. - **HTTP requests** - the natural extension once we hit a load-bearing platform with no first-class CLI (Forgejo). Guard the request, not the subprocess. - **MCP** - the obvious next surface once you think about the ways agents cause chaos through tool servers rather than shells. - **pkg** - everything shared across the three: audit, policy, scope, the config/cache plumbing. ## Why now - The four buckets are independently coherent. A reader looking for "how do we guard an HTTP call" should land in one subtree, not grep 40 dirs. - New surfaces (HTTP, MCP) were bolted onto a layout designed for one surface (CLI). The seams are now load-bearing enough to make explicit in the package tree. - Import direction becomes enforceable: surfaces depend on `pkg`, never on each other. Today nothing stops `egress` importing `passthrough`. ## Proposed bucketing (starting point, not final) **CLI passthrough surface** `passthrough`, `verb`, `repocfg`, `allowlist`, `catalog`, `gittree`, `hook`, `hookcfg`, `shim`, `doctor`, `sudo`, `shell`, `dispatch`, `lockdown`, `profiles`, `profile`, `decision` **HTTP surface** `egress`, `specverb`, `guardfile`, `specgen`, `specdrv`, `respfmt`, `ghcache`, `ghidcache`, `ghratelimit`, `stscache` **MCP surface** `mcporter` **pkg (shared core)** `audit`, `policy`, `scope`, `exitcode`, `config`, `ttlcache`, `workdir`, `skillgen` `cmd/`, `docs/`, `examples/`, `scripts/` stay where they are. ## Open questions - Nesting depth: `surface/http/egress` vs. a flat `http/`? Go discourages deep nesting but the surface prefix is the point. Lean shallow: `cli/`, `http/`, `mcp/`, `pkg/`. - Where do the `*cache` packages live - under `pkg` (generic) or under the surface that drives them (`gh*` under `http`, `stscache` under... CLI? both)? Probably `pkg/cache` for the generic TTL machinery, surface dirs for the domain-specific caches. - `decision` / `profiles` / `lockdown` are cross-surface (they gate both CLI and potentially HTTP/MCP). May belong in `pkg`, not the CLI bucket. - Import-path churn: this renames every package path. Module is `forgejo.coilysiren.me/coilyco-flight-deck/cli-guard/<pkg>`, so every consumer (ward, coily) re-imports. Needs a single atomic rename commit + a coordinated consumer bump off the resulting tag. cli-guard is the base of the triple, so sequencing matters. - Whether to add a `surfaces.md` / update `docs/FEATURES.md` to group the inventory by surface in the same pass. ## Scope of work 1. Agree the four buckets and the package-to-bucket assignment (this issue). 2. One atomic move/rename commit (`git mv` + import rewrites, `goimports`). 3. Re-render `docs/FEATURES.md` grouped by surface; add a short `docs/architecture.md` naming the surfaces and the `pkg`-only-downward import rule. 4. Coordinated consumer bump (ward, coily) off the new tag. Filed from a design conversation - the bucketing above is a proposal to react to, not a settled plan.
Author
Owner

Evidence from the speakeasy-ward proving slice + a usage-weighted read on the buckets

Landed the consumer-side "front, don't port" slice in
speakeasy-ward:
a generated client guards org-delete by calling a Go endpoint over cli-guard's
decision + audit + profiles, with zero enforcement reimplemented in the
generated language. Two things fell out that bear directly on this reorg.

1. decision / profiles / audit are pkg, not CLI - now proven, not asserted

The open question flagged decision/profiles/lockdown as maybe-cross-surface.
The slice settles it for the first two. An HTTP caller in a different repo
imported exactly decision, profiles, and audit and nothing else from the
CLI bucket. They gate the HTTP front today, in code, with no passthrough/verb
dependency. They belong in pkg. lockdown is still CLI-flavored (it drives the
deny-list render), so the split is real: the evaluator (decision/profiles/
profile) is shared, the enforcer-of-a-shell-boundary (lockdown) is CLI.

2. The endpoint is a surface-agnostic exposure of pkg, not an HTTP thing

The slice exposed decision+audit as a tiny HTTP endpoint and let a generated
TS client call it. But nothing about that endpoint is HTTP-specific. All three
fronts can reach the same enforcement plane the same way:

  • CLI passthrough - calls decision/audit in-process, no endpoint needed.
  • HTTP - generated or hand-written client calls it via a request hook.
  • MCP - a proxy calls it before dispatching a tool.

So the decision/audit core wants to sit in pkg and be callable three ways. The
endpoint server is a thin adapter, not a member of the HTTP bucket. If anything
it argues for a pkg-level enforcement API that each surface adapts.

3. The front is chosen by "is there a good CLI to wrap?", not "is there an OpenAPI?"

Bucketing the real top integrations by which surface actually guards them
(most-used first):

  • CLI passthrough - gh, aws, brew, kubectl, git, tailscale,
    gcloud, az, uv, npm, cargo, go, ssh. ~13 of the top set.
  • HTTP, typed-operation guard - forgejo (no first-class CLI), trello
    (no CLI, weak spec). 1, maybe 2.
  • HTTP, egress/allowlist - the otel stack (datadog, honeycomb,
    new relic, all otel-wrapped) and package-manager registry fetches.
  • MCP - mcporter.

The seam rule the data exposes: several of these ship an OpenAPI spec and we
still guard the CLI
(gh/GitHub, kubectl/k8s, az/Azure). The front is
picked by CLI maturity, not spec availability. Forgejo is on HTTP because it has
no good CLI, not because it has a spec. That means the HTTP typed-operation
front stays small by construction: most platforms ship a CLI, so HTTP is the
exception for the CLI-less ones.

4. The HTTP bucket is two shapes, and Speakeasy touches only the smaller one

The proposal's HTTP bucket lumps egress with specverb/guardfile/specgen/
specdrv/respfmt. By usage those are two different shapes:

  • egress / allowlist (egress, the CONNECT-proxy concern) - "what hosts may
    this send to." Serves the otel stack and registry fetches. The heavier HTTP
    shape by usage.
  • typed-operation guard (guardfile/specverb/specgen/specdrv + the
    Overlay) - "may this caller invoke this operation." Serves ~forgejo.

Speakeasy only ever helps the second shape (typed clients from OpenAPI), which by
this data is one backend. The enforcement value there is the KDL->Overlay
default-deny compile (overlaygen, ours) plus the endpoint, neither of which is
Speakeasy's. Conclusion for sequencing: Speakeasy is a demo-grade caller
factory, deletable without touching the boundary.
Don't let the consumer-bump
step (item 4 of scope) treat it as a dependency to preserve. The thing to keep
stable across the rename is the pkg enforcement API and the Overlay compiler.

Smaller reaction: gh*cache placement

The proposal puts ghcache/ghidcache/ghratelimit under HTTP, but GitHub is
guarded via the gh CLI passthrough, not an HTTP front. If those caches back
gh operations they belong with CLI. They only belong in HTTP if cli-guard makes
direct GitHub HTTP calls independent of gh (rate-limit/id resolution). Worth
pinning down before the move, since it's the same kind of "front chosen by CLI
presence" call as above.

Net for the bucketing

  • cli/ is the heavyweight by usage (~13 integrations). Size the reorg effort
    accordingly.
  • http/ is real but small, and internally splits into egress (broad) and
    typed-operation (~forgejo). Consider naming both rather than one http/.
  • pkg/ should hold decision, profiles, profile, audit, policy,
    scope - the slice consumed the first four cross-repo.
  • The decision/audit endpoint adapter is a pkg-level API surfaced three
    ways, not an HTTP-bucket package.
## Evidence from the `speakeasy-ward` proving slice + a usage-weighted read on the buckets Landed the consumer-side "front, don't port" slice in [speakeasy-ward](https://forgejo.coilysiren.me/coilyco-proving-ground/speakeasy-ward): a generated client guards `org-delete` by calling a Go endpoint over cli-guard's `decision` + `audit` + `profiles`, with zero enforcement reimplemented in the generated language. Two things fell out that bear directly on this reorg. ### 1. `decision` / `profiles` / `audit` are `pkg`, not CLI - now proven, not asserted The open question flagged `decision`/`profiles`/`lockdown` as maybe-cross-surface. The slice settles it for the first two. An HTTP caller **in a different repo** imported exactly `decision`, `profiles`, and `audit` and nothing else from the CLI bucket. They gate the HTTP front today, in code, with no `passthrough`/`verb` dependency. They belong in `pkg`. `lockdown` is still CLI-flavored (it drives the deny-list render), so the split is real: the *evaluator* (`decision`/`profiles`/ `profile`) is shared, the *enforcer-of-a-shell-boundary* (`lockdown`) is CLI. ### 2. The endpoint is a surface-agnostic exposure of `pkg`, not an HTTP thing The slice exposed `decision`+`audit` as a tiny HTTP endpoint and let a generated TS client call it. But nothing about that endpoint is HTTP-specific. All three fronts can reach the same enforcement plane the same way: * **CLI passthrough** - calls `decision`/`audit` in-process, no endpoint needed. * **HTTP** - generated or hand-written client calls it via a request hook. * **MCP** - a proxy calls it before dispatching a tool. So the decision/audit core wants to sit in `pkg` and be callable three ways. The endpoint server is a thin adapter, not a member of the HTTP bucket. If anything it argues for a `pkg`-level enforcement API that each surface adapts. ### 3. The front is chosen by "is there a good CLI to wrap?", not "is there an OpenAPI?" Bucketing the real top integrations by *which surface actually guards them* (most-used first): * **CLI passthrough** - `gh`, `aws`, `brew`, `kubectl`, `git`, `tailscale`, `gcloud`, `az`, `uv`, `npm`, `cargo`, `go`, `ssh`. ~13 of the top set. * **HTTP, typed-operation guard** - `forgejo` (no first-class CLI), `trello` (no CLI, weak spec). 1, maybe 2. * **HTTP, egress/allowlist** - the otel stack (`datadog`, `honeycomb`, `new relic`, all otel-wrapped) and package-manager registry fetches. * **MCP** - `mcporter`. The seam rule the data exposes: **several of these ship an OpenAPI spec and we still guard the CLI** (`gh`/GitHub, `kubectl`/k8s, `az`/Azure). The front is picked by CLI maturity, not spec availability. Forgejo is on HTTP because it has *no* good CLI, not because it has a spec. That means the HTTP typed-operation front stays small by construction: most platforms ship a CLI, so HTTP is the exception for the CLI-less ones. ### 4. The HTTP bucket is two shapes, and Speakeasy touches only the smaller one The proposal's HTTP bucket lumps `egress` with `specverb`/`guardfile`/`specgen`/ `specdrv`/`respfmt`. By usage those are two different shapes: * **egress / allowlist** (`egress`, the CONNECT-proxy concern) - "what hosts may this send to." Serves the otel stack and registry fetches. The heavier HTTP shape by usage. * **typed-operation guard** (`guardfile`/`specverb`/`specgen`/`specdrv` + the Overlay) - "may this caller invoke this operation." Serves ~`forgejo`. Speakeasy only ever helps the second shape (typed clients from OpenAPI), which by this data is one backend. The enforcement value there is the KDL->Overlay default-deny compile (`overlaygen`, ours) plus the endpoint, neither of which is Speakeasy's. Conclusion for sequencing: **Speakeasy is a demo-grade caller factory, deletable without touching the boundary.** Don't let the consumer-bump step (item 4 of scope) treat it as a dependency to preserve. The thing to keep stable across the rename is the `pkg` enforcement API and the Overlay compiler. ### Smaller reaction: `gh*cache` placement The proposal puts `ghcache`/`ghidcache`/`ghratelimit` under HTTP, but GitHub is guarded via the `gh` **CLI passthrough**, not an HTTP front. If those caches back `gh` operations they belong with CLI. They only belong in HTTP if cli-guard makes direct GitHub HTTP calls independent of `gh` (rate-limit/id resolution). Worth pinning down before the move, since it's the same kind of "front chosen by CLI presence" call as above. ### Net for the bucketing * `cli/` is the heavyweight by usage (~13 integrations). Size the reorg effort accordingly. * `http/` is real but small, and internally splits into **egress** (broad) and **typed-operation** (~forgejo). Consider naming both rather than one `http/`. * `pkg/` should hold `decision`, `profiles`, `profile`, `audit`, `policy`, `scope` - the slice consumed the first four cross-repo. * The decision/audit **endpoint adapter** is a `pkg`-level API surfaced three ways, not an HTTP-bucket package.
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#113
No description provided.