inherit: never denials propagate upward + an override keyword for conscious upward escalation (fail-closed tiering) #169
Labels
No labels
burndown-2026-06
sunday-sprint
coherence-core
consult
headless
interactive
P0
P1
P2
P3
P4
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
coilyco-flight-deck/cli-guard#169
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?
Context
ward is migrating its whole
ward-kdlops surface to permission tiers -ward-kdl-read ⊂ ward-kdl-write ⊂ ward-kdl-admin, one standalone binary per tier, loaded by context (read in CI containers, write in warded feature containers, admin native). forgejo already runs this chain viainherit(cli-guard#160). The full migration (ward epic ward#339) extends it to every area.Posture: fail-closed, build-up. read is the least-privileged base; the dangerous capabilities are denied there and only the trusted admin tier re-grants them, by name.
The model (what the engine must guarantee)
1. Everything inherits upward, automatically
A tier under
inheritautomatically carries every rule of the tier it inherits -can,never,restrict, and thespec/base-url/authsingletons - and re-declares nothing. This supersedes the child-localrestrictof cli-guard#160: forgejo's tiers restaterestrict owner matches coily*today only becauserestrictdoes not inherit; under this model that boilerplate goes away.2. The least-privileged base explicitly
nevers dangerous actionsread does not merely omit delete - it declares
never delete(andnever fork,never archive, the repo-label denials, etc.). Deny-by-default is made explicit at the most-exposed tier, so the dangerous capability is actively forbidden where the binary is least trusted, and a later carelesscancannot silently re-open it (see precedence). Thesenevers inherit upward, so they hold at write and admin too - unless a tieroverrides one (below).3.
override: the sole way a higher tier crosses an inheritedneverThe most-privileged tier re-grants a specific denied action by name:
overridecrosses anever. A plaincannever does (precedence, below).never delete *in read and onlyoverride can delete repoin admin,delete issueis still refused at every tier.4. Precedence (the load-bearing rule)
An inherited
neverbeats a plaincan. The only construct that beats an inheritedneveris anoverridein a higher tier naming the exact verb+resource.This makes wildcard grants safe: a tier may write
can delete *orcan create *and the inheritednever delete issue/never create labelstill block their carve-outs. Deny is sticky upward; nothing but a namedoverridereverses it.Worked example (forgejo, the ward#278 surface as base-nevers + admin-overrides)
never delete *,never fork repo,never archive repo,never unarchive repo,never create org,never delete org, the repo-label denials (never create label,never edit label),never delete issue,never view pr,never list pr.create/edit(curated). The inheritednever create labelstill beats a broadcan create- no override, so it stays denied.override can delete repo,override can delete milestone,override can delete release,override can delete org-label- and only those.delete issue,delete orgare never overridden, so they stay denied at admin. That is exactly the curated forgejo policy, now expressed as deny-low + override-high.Acceptance
inherit, a tier inherits all rules with no re-declaration, proven by a test where a baserestrict/nevertakes effect in an un-restating higher tier.can(including wildcardcan <verb> *) cannot cross an inheritednever: testcan delete *at a tier + inheritednever delete issue=> delete issue still refused.override can <verb> <resource>does cross the inheritednever, for that resource only: testoverride can delete repogrants repo delete while inheritednever delete *keepsdelete issuerefused.overrideis rejected at build time if there is no inheritedneverit lifts (no silent no-op overrides), and a plaincanthat would need to cross aneveris a build error pointing the author atoverride.override" section and notes therestrict-now-inherits change.Security note
Fail-open-risk change to the enforcement engine - the precedence rule plus
overrideare the load-bearing parts. The whole point is that nothing except a namedoverridecan re-open a basenever. Recommend it land with review, not fire-and-forget to main.References
ward#339 (the tier-migration epic), ward#278 (forgejo port - authors against this model), ward#240 (the chain), cli-guard#160 (
inherit+ the child-localrestrictthis supersedes), cli-guard#159 (wildcard grants - safe under the precedence rule).Tracked under the ward tier-migration epic ward#339. Engine prerequisite for every area that selectively escalates past a broad base denial (forgejo: allow delete repo, keep never delete issue - ward#278). Security-sensitive: recommend review, not fire-and-forget to main.
everything inherits upwards, automatically
🔒 Reserved by
ward agent --driver claude— containerward-cli-guard-issue-169-claude-593e4f07on host4f2062d4fed1is carrying this issue (reserved 2026-06-25T18:16:36Z). Concurrentward agentruns are blocked until it finishes or the reservation goes stale (2h0m0s TTL);--forceoverrides.— Claude (she/her), via
ward agentRetro from the container that built this 🐾
The engine work landed faster than I expected — the existing
inheritflatten + deny-wins machinery was already shaped right, so this was mostly addingoverrideas acan-with-a-flag and teaching three resolve sites to let it cross a deny.The part that actually fought back was precedence. There were existing same-tier
can+nevertests that expect a silent deny-wins, but #169 wants an explicitcanover an inheritedneverto be a loud build error. Reconciling those nearly pushed me toward plumbing provenance through the typed model — until I realized inherited-vs-local is only knowable at the flatten/merge seam, and the wildcard-coverage check (never delete "*"coverscan delete repo) is purely textual, no spec needed. Doing the validation right there kept it clean and left every pre-existing test untouched. One genuine bug I caught mid-build: a tier that restates an inheritedneverdeduped it out of the prefix and made a legit override look like a no-op — fixed by validating against all parent denies, not the deduped set.Honestly the pre-commit gate was the bigger slog than the feature: golangci-lint and trufflehog weren't in the container, the 80-line/4000-char doc cap forced splitting out
specverb-override.md, and the ≤2-line comment rule meant trimming ~25 comments.Confidence is high — acceptance criteria each have a test, build errors included. Rough edges worth a follow-up: a degenerate same-file
never+overrideis allowed a bit loosely, and the real stress test is ward#278 porting the full forgejo surface onto deny-low + override-high. (Note: the 3cli/sandboxseccomp test failures are pre-existing and environmental — this linuxkit box can't enforce seccomp; unrelated to this change.)