specverb + KDL Guardfile: spec-driven verb engine with a non-executable policy DSL #75
Labels
No labels
burndown-2026-06
sunday-sprint
coherence-core
consult
headless
interactive
P0
P1
P2
P3
P4
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
coilyco-flight-deck/cli-guard#75
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Replace the per-verb, hand-rolled CLI wrappers in cli-guard's consumers with a spec-driven engine plus a KDL policy DSL. One generic Go engine (
specverb) builds the guardedcli.Commandtree at runtime from an embedded, pinned API spec plus a compiled overlay. The overlay is authored as a human-readable, non-executable KDL Guardfile that compiles down to a standard OpenAPI Overlay.This unifies two existing consumer issues into one piece of cli-guard infra:
Both are the same shape: a thin engine over an annotated upstream spec, behind cli-guard's policy + audit boundary. Build it once in cli-guard, consumed by ward, coily, and kap.
The layer stack
swagger.v1.json).x-coily-*extensions. The compiled, deterministic policy IR. Standard OAI Overlay (JSON/YAML), targeting the spec by JSONPath. This is the interchange format other tooling (Speakeasy, oas-patch, libopenapi) understands, and it is what the runtime consumes.KDL replaces the authoring layer only. The overlay stays as the compiled artifact so the runtime targets a stable, pinned, hash-verified input.
The runtime engine (
specverb, lives in cli-guard)The generated Go we have today is ~90% static boilerplate (
doRequestAndStream,readBody,fetchSSM) plus a tiny per-op descriptor ({method, path, pathParams, queryParams, hasBody, authNeeded, verbName}). A runtime engine builds[]opDescriptorfrom the parsed spec + overlay and binds them to one generic action. The thousands of lines of*_generated.go(and coily's Pythonopenapi-to-coily.py) collapse into one tested engine.Maximally-generic form: embed the spec + overlay via
go:embed, build thecli.Commandtree at runtime (lazy/cached), mount under cli-guard'sverb.Wrap+audit.Writer.Deny-by-default allowlist: an operation is mounted if and only if it carries an
x-coily-verbannotation. The overlay is then purely additive (allupdateactions, no fragileremovesweeps), and the set of annotated ops is the allowlist.x-coily-*vocabulary (what the engine reads)x-coily-verb- REQUIRED to mount. Value"<group> <leaf>". Its presence is the allowlist.x-coily-args- ordered positional args drawn from path params.x-coily-body-flags- promote request-body scalar fields to typed flags (required schema fields -> required flags). Omit -> single raw--body(literal /@file/-).x-coily-fmt- namedrespfmthook for curated output. Absent -> pretty JSON.x-coily-resolve- named pre-call hook for non-declarable cases (e.g. label name->id, the coily#159 behavior).x-coily-allow-redirect- defaults false on mutating methods. Set true only to opt out of the 301 refusal (the coily#178 / #160 fix, now structural).The KDL Guardfile (L2 authoring)
Chosen over a literal-Ruby internal DSL. A Ruby Guardfile would be
eval'd - arbitrary code execution at build time, which is attack surface and is backwards for a tool (ward) whose job is to keep hostile code from running. KDL is pure data: parsing, not evaluation, so there is no code to smuggle in. It also ships a schema language (author-time validation) and a CSS-selector query language, and it is in-house (Kat Marchan's language).Nodes are the modals (
can/cannot/never), arguments are verb-classes, properties carry scope and exceptions.Security invariants (non-negotiable)
Two enforcement axes (the DSL authors both)
cannot delete) -> compiles to L1 overlay (mount/deny ops). Thex-coilyaxis.secret get ssn => DENY,secret get login => APPROVE) -> compiles to cli-guard's argv policy (policy/scope/egress). Value-level rules must lower to concrete identifiers/patterns, fail-closed on anything unmatched.The verb-class -> operation-set expansion (
deletecatchingTerminateInstances,RevokeGrant, ...) is the one fuzzy step. It runs in the compiler with an LLM-assisted-or-curated, human-reviewed, committed expansion table - never at runtime.Findings from today's spec dig (Forgejo)
parameters[in:body](requestBodyis null), security is global. The engine needs a 2.0 reader or a 2.0->3.x upgrade pass (kin-openapiopenapi2conv).Authorization: token <key>, notBearer.securityDefinitions.AuthorizationHeaderTokenrequires thetokenprefix. None of the existing generator auth modes cover this - the engine needs a declarable header-token scheme.repo createis genuinely missing today and is the first proving slice:POST /user/repos,operationId: createCurrentUserRepo, bodyCreateRepoOption(required:name; scalars:private,description,auto_init,default_branch, ...). Org variant:POST /orgs/{org}/repos(createOrgRepo).First proving slice
Drive
coily ops forgejo repo createend-to-end through the chain: KDL Guardfile -> compile ->forgejo.overlay.yaml->specverbbuilds the verb -> POST against the live Forgejo API withtokenauth and body-flag assembly. Validates Swagger-2.0 read, the new auth scheme, deny-by-default mounting, and the 301 default in one shot.Open decisions
calico32/kdl-go(v1+v2, passes upstream suite) keeps the compiler all-Go, vs Kat's reference Rustkdl-rs. Build-time only either way - never ships in the binary.specverbin cli-guard, consumed by ward/coily/kap. Confirm the KDL Guardfile vocabulary also ships from cli-guard (shared), with each CLI keeping its own Guardfile(s) undercmd/.Prior art
openapi-overlay(Go), pb33f libopenapi,oas-patch(Python).openapi2convfor Swagger 2.0 -> OpenAPI 3.x.Filed by Claude during a design conversation with Kai. Companion to coily#186 and ward#51.
Regenerated plan (2026-06-06)
Re-derived from scratch, then reconciled against the L0/L1/L2 + specverb design above. The architecture in this issue holds. What follows refines the engine home, consumer ordering, proving slice, and Guardfile UX, and lays out a milestone ladder.
Topology: cli-guard => ward first
specverb(runtime engine) + aguardfilecompiler land as new packages beside the existingverb/audit/policy/scope/egress/respfmtrails, reusing them.cli-guard v0.3.0and has zero forgejo verbs today, so forgejo-in-ward is greenfield. Nothing to migrate, nothing to break. This is theward#51CLI-argv surface.ops forgejo repo createis the behavioral reference: ward's specverb-built verb must produce the same API call. coily migrates to specverb later (coily#186).Iteration-loop friction to manage
Engine in cli-guard is a versioned dep ward pins. The fast dev loop uses a
go.mod replacein ward pointing at the local cli-guard checkout for M0-M3, dropped and bumped tov0.4.0before ward ships. Watch: pre-commit may flag areplacedirective; tolerate or gate it in dev.Proving slice: repo create + delete as a self-cleaning pair
Not load-bearing for automation, so safe to rebuild from scratch. As a pair they give a self-cleaning integration test: create a throwaway repo, assert, delete it, assert gone. create exercises body-flags + token auth + 301; delete exercises deny-by-default mount + destructive-confirm UX.
Fanatical UX is the thesis, not DRY
A generic engine is how you get uniform, excellent UX across every verb at once and one-sentence-to-add-a-verb iteration. The boilerplate collapse is a side effect. Written once in specverb, inherited everywhere:
--dry-run(print resolved request, no fire), required-body-field -> required-flag with teaching errors, fail-closed denials that name the annotation that would lift them,--yesconfirms on destructive ops, therespfmt--query/--outputprojection rail, generated help + examples.Guardfile UX: flat declarative sentences
Policy body reads as plain English a vibe coder parses on sight. One fact per line, bare positional tokens, controlled vocabulary. Quotes quarantined to a write-once config header.
Design rule: the daily-edited policy body must be expressible entirely in bare KDL tokens. Modals
can/cannot/never, a verb set, a resource set. Exceptions go flat and positive (can delete labels created-by-me, notcannot delete except=label:created-by-me). Properties + child blocks stay a legal escape hatch off the happy path. An M2 lint fails the Guardfile if any token in acan/cannot/neversubtree requires quoting.This quietly collapses the NLP layer into the authoring layer: the Guardfile body is a controlled natural language, with KDL's parse-not-evaluate safety.
Milestone ladder
specverbengine +guardfilecompiler, thin enough to mount exactly one verb from a fixture spec,--dry-runworking. Unit-tested in cli-guard, no ward yet. (KDL: a subset parser unblocks the engine now; thecalico32/kdl-goswap + bare-token conformance check is the named follow-up, not a silent skip.)can create repos/can delete repos. specverb mountsward ops forgejo repo create|delete. dry-run, then live, diffed against coily's hand-written verb. create+delete = self-cleaning integration test.respfmtrail,--yes.v0.4.0, drop ward's replace, bump ward. Migrate coily's hand-written verbs to specverb (coily#186) and embed-plus-pin the spec by hash.Open decisions, resolved
Overlay apply timing - runtime-apply for the proto, embed+pin deferred to M4.
KDL parser -
calico32/kdl-go, build-time only, swapped in behindguardfile.Parse(subset parser bridges M0).Engine home - cli-guard, prototyped with ward as first consumer; coily migrates at M4.
Overlay tooling - Speakeasy
openapi-overlay(Go) is the L1 apply library. The KDL Guardfile (L2) compiles to a standard OpenAPI Overlay doc carrying thex-cli-guard-*extensions; Speakeasy applies it onto L0 to yield the annotated spec the engine mounts from. No hand-rolled overlay application.Extension prefix:
x-cli-guard-(not the issue's originalx-coily-*). The engine lives in cli-guard and is consumed by ward/coily/kap, so the vocabulary carries the engine's name, not one consumer's. Sox-cli-guard-verb(REQUIRED to mount, presence is the allowlist),x-cli-guard-args,x-cli-guard-body-flags,x-cli-guard-fmt,x-cli-guard-resolve,x-cli-guard-allow-redirect.Still open: which forgejo surface ward genuinely needs (aims M3).
Session checkpoint: M0 status + specverb design notes
Handoff so a fresh session starts at the engine, not by re-deriving. Supersedes the prior comment's "subset parser bridges M0, kdl-go is a follow-up" note.
Done this session (landed in cli-guard,
make test/make vetgreen)guardfilepackage parses the flat-sentence KDL Guardfile into a typed model (Group,Spec,BaseURL,Auth,Grants). Fail-closed on unknown nodes, missing required fields, malformed sentences, and unsupported auth schemes.calico32/kdl-go v0.14.1, not a stub.forgejo.swagger.v1.jsonand the flatcan delete labels created-by-meparse as bare identifiers. Only/and the trailing-spaceprefix "token "force quotes, and those are exactly the config-header tokens. The "policy body stays quote-free" rule is confirmed by the parser.speakeasy-api/openapi-overlay v0.10.3confirmed resolvable.guardfile/guardfile.go,guardfile/guardfile_test.go,guardfile/testdata/forgejo.kdl, plusgo.mod/go.sum.Next: the
specverbengine (design decided this session)openapi2convSwagger-2.0 -> 3.x is M1, not M0.(verb, resource) -> {cliGroup, cliLeaf, operationId}, e.g.{create, repos} -> {repo, create, createCurrentUserRepo}. Deny-by-default: no entry, no mount. This is the human-reviewed table the issue calls for.--dry-runprints the resolved request (method, URL, headers, body) without firing. Live path fires then runsrespfmt.Renderfor the--query/--outputrail.TokenResolverinjection. specverb takes afunc(ctx, ssmPath) (string, error)so the AWS SDK stays OUT of cli-guard (repo-boundary rule: no consumer-shaped deps leak in). ward wires the real SSM resolver; tests inject a fake.["ward","ops","forgejo"]) -> tree root; resource (plural) -> cli group (singular noun); verb -> cli leaf.specverb.Buildreturns the leaf group command the consumer mounts.x-cli-guard-*extensions -> Speakeasy applies onto L0 -> engine mounts ops carryingx-cli-guard-verb.Repo facts for the next session
make(make test/vet/tidy), not bare go.godoc-current.txt(regen on API change viascripts/check-godoc-current.sh --update).go.mod replaceto local cli-guard for M0-M3, dropped and taggedv0.4.0at M4.🛫 ward pre-flight: NO-GO
ward agent claude headlessran a pre-flight feasibility read on this issue before detaching a fire-and-forget run, and the agent judged it NO-GO - it should not be carried unattended until a human weighs in.No container was launched. Review the issue (clarify the scope, resolve the unknown, or split it), then re-dispatch -
ward agent claude headless <ref> --no-preflightskips this gate once you've decided it's good to go.full pre-flight read
This issue is a multi-milestone design epic (M0–M4) spanning cli-guard, ward, and coily, with explicitly-open decisions ("which forgejo surface ward genuinely needs" still open) and no single bounded done-condition — the M0 slice alone wants a Swagger-2.0 reader, a curated expansion table, Speakeasy overlay wiring, and a generic action, and the proving slice depends on live Forgejo API calls with
tokenauth (SSM secrets + network) that an ephemeral container can't exercise or verify. The guardfile package already landed per the latest checkpoint, so the home is right — but "carry to merge unattended" has no crisp target here without a human picking the exact slice. This needs scoping, not autonomous execution.NO-GO: open-ended multi-milestone epic with open design decisions and live-API/secret dependencies; no bounded, container-verifiable done-condition for one unattended run.
Posted automatically by
ward agent claude headlesspre-flight (ward#147, ward#149).— Claude (she/her), via
ward agentthis is mostly done already actually