fix(cmd): refuse to arm APPROVE on an empty binding require - #150
Merged
Merged
Conversation
…9 locale class) $SONAR_IF_NONPR_ARM… (unbraced, immediately followed by a UTF-8 ellipsis) is parsed by bash as a variable whose name absorbs the ellipsis's leading byte under a UTF-8 locale, so check_sonar_scan_wired's constant-if branch died with 'unbound variable' under set -u instead of returning its finding — the gate was RED on a clean tree for a developer with LANG=C.UTF-8/en_US.UTF-8 and green under C. Same class as D-179. Brace the expansion; no behaviour change.
…D-182) A schema-valid RulesetBinding with require: [] (or no require: key) decided APPROVE with zero findings for any governed change its block rules did not fire on: the obligation layer iterates bind.Require, so an empty list made it vacuous. The schema accepted the shape and lint passed it, while GUIDELINES Safety-1 and the record.go seam note (S03 review F3) both promised the opposite. - run path: decide's decidable, non-reserved arm now returns a hard error when the covering binding's require is empty, before any forge write. Placed in decide (not selectBinding) because selectBinding is shared with 'assent compare', whose purpose is to compare permissive candidates. - lint: new hard error binding-require-empty + good/bad fixture pair in the E3-S08 hardErrorCorpus. The reserved assent-policy class is exempt: it is block-by-default (GUARD 1) and cannot carry a non-empty require, since any prove rule in a reserved-bound pack is itself a reserved-class violation. - reconciliation: D-182 supersedes D-021's 'vacuously covered' clause; the frozen schema's minItems:1 is deferred to its next change window (D-132 freeze guard). lint-hard-errors.md, cli.md and the record.go seam note updated. fail-open fixtures gain require: [reviewed] (their rule already proved it); they were 'clean' only because the check did not exist.
… run-path refusal
konih
force-pushed
the
fm/assent-b3-empty-require
branch
from
September 26, 2026 15:51
a7e6ab2 to
455586c
Compare
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Intent
Assent's run path arms APPROVE with zero findings on a schema-valid RulesetBinding whose require: is empty or absent. The schema does not require require and sets no minItems (schemas/policy/v1alpha1/ruleset-binding.schema.json:52-57); the obligation layer starts at APPROVE and iterates bind.Require, so an empty list is vacuous — any governed change the block rules do not fire on is approved with no positive vouch, and assent lint does not catch it. The repo's own seam note assigns the CLI the duty to guarantee require is non-empty before an APPROVE is armed (internal/core/decision/record.go:188-192) and GUIDELINES.md §Safety-1 says every change must be positively vouched, yet the schema description and D-021 bless the shape as vacuously covered. Add the guard so this can never arm APPROVE: fail closed on an empty require on the run path, surface it as a lint hard error, pin the polarity with a test that an empty require never APPROVEs, and reconcile the D-021/schema vacuously covered wording in this lane (the schema minItems change is deferred to its next change window).
What Changed
require[]:decidereturns a hard error naming the binding(class, environment)beforebuildEvaluationInput, so no APPROVE record is built and zero forge writes occur. The reservedassent-policyclass is unaffected (any.assent/**edit already BLOCKs).assent lintgains thebinding-require-emptyhard error, located to the binding and exempting the reserved meta-class, with agood/badfixture pair added to thehardErrorCorpusand thefail-openfixtures updated to declarerequire: [reviewed].internal/core/decision/record.go; the schemaminItems: 1change remains deferred under the frozen-schema guard.Risk Assessment
✅ Low: The change is well-bounded: it adds one fail-closed guard on the only CLI path that arms APPROVE plus one lint hard error with a good/bad fixture pair, all other Cover callers are explicitly out of scope, and the guard cannot be bypassed by empty/whitespace require entries.
Testing
Drove the real
assentbinary end-to-end over HTTP against a standalone fake GitLab server (same endpoint surface as the in-repo httptest fake). Emptyrequire: []and absentrequire:both exit 1 with a contributor-readable refusal naming the binding and perform zero forge writes; the pre-change base binary on the same input exits 0 with APPROVE + approve + merge, reproducing the closed fail-open; a non-empty binding still approves and merges (merge pinned to srcSHA), so the happy path is intact.assent linton the new fixture pair emits exactly the locatedbinding-require-emptyhard error for the bad tree and lints the good tree clean, with the reservedassent-policycarve-out clean. Targeted Go tests for the run-path polarity and the lint corpus pass. No visual evidence was produced because this change has no UI surface (CLI exit codes, stderr diagnostics, and forge-write state are the user-observable surfaces).assent run --armon a decidable APPROVE-shaped change covered by a binding withrequire: []and observe a fail-closed refusal with zero forge writesassent run --armon the same change with therequire:key entirely ABSENT (schema-valid) and observe the same fail-closed refusalrequirestill arms APPROVE and merges the change (guard does not break the legitimate path)assent linton a repo whose binding omitsrequireand observe the locatedbinding-require-emptyhard error and non-zero exitassent lint examples/lint-fixtures/binding-require-empty/bad→ exit=1, stderrerror: [binding-require-empty] .assent/bindings.yaml (class=demo environment=prod): .... lint_scenarios.txtassent linton the clean fixture whose binding declares a provenrequireand observe exit 0assent lint examples/lint-fixtures/binding-require-empty/good→ exit=0,assent lint: clean. lint_scenarios.txtassent linton the reservedassent-policyfixture with an emptyrequireand observe it is exempt (clean exit 0)assent lint examples/lint-fixtures/reserved-class/good→ exit=0,assent lint: clean. lint_scenarios.txtminItems: 1deferred to its next change windowEvidence: Run-path refusal: empty require
assent run: evaluate: ruleset-binding binding (class="topic-registry", environment="prod") declares no required obligations (require is empty) — refusing to arm APPROVE; add at least one obligation to require[] (GUIDELINES §Safety-1, D-183)Evidence: Run-path refusal: absent require
Evidence: Differential: pre-change base binary APPROVEs + merges on empty require (fail-open reproduction)
{"apiVersion":"assent.dev/v1alpha1","kind":"DecisionRecord","decision":"APPROVE",...} decision=APPROVE arm=true → 3 forge operation(s) writtenEvidence: Positive control: non-empty require still APPROVEs and merges
{"apiVersion":"assent.dev/v1alpha1","kind":"DecisionRecord","decision":"APPROVE",...} decision=APPROVE arm=true → 3 forge operation(s) writtenEvidence: Lint scenarios: bad fixture hard error, good + reserved-class clean
Evidence: Standalone fake GitLab server harness used to drive the real binary
Evidence: Evidence summary
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
🔧 **Rebase** - 2 issues found → auto-fixed ✅
cmd/assent/run_test.go- merge conflict rebasing onto origin/maindocs/decisions/decisions.md- merge conflict rebasing onto origin/main🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
assent run --armon a decidable APPROVE-shaped change covered by a binding withrequire: []and observe a fail-closed refusal with zero forge writesassent run --armon the same change with therequire:key entirely ABSENT (schema-valid) and observe the same fail-closed refusalrequirestill arms APPROVE and merges the change (guard does not break the legitimate path)assent linton a repo whose binding omitsrequireand observe the locatedbinding-require-emptyhard error and non-zero exitassent lint examples/lint-fixtures/binding-require-empty/bad→ exit=1, stderrerror: [binding-require-empty] .assent/bindings.yaml (class=demo environment=prod): .... lint_scenarios.txtassent linton the clean fixture whose binding declares a provenrequireand observe exit 0assent lint examples/lint-fixtures/binding-require-empty/good→ exit=0,assent lint: clean. lint_scenarios.txtassent linton the reservedassent-policyfixture with an emptyrequireand observe it is exempt (clean exit 0)assent lint examples/lint-fixtures/reserved-class/good→ exit=0,assent lint: clean. lint_scenarios.txtminItems: 1deferred to its next change windowCGO_ENABLED=0 go build -o /tmp/assent-rvw-s01 ./cmd/assent(target)git archive <base> | tar -x -C /tmp/assent-base-src && go build -o /tmp/assent-base ./cmd/assent(pre-change baseline)GITLAB_TOKEN=tok /tmp/assent-rvw-s01 run --gitlab-endpoint http://127.0.0.1:<port> --project 42 --mr 7 --bot-author assent-bot --subject file:topics/orders.yaml --armwith fake GitLab mode=empty (exit 1, zero writes)sameruninvocation with fake GitLab mode=absent (exit 1, zero writes)/tmp/assent-base run ... --armwith fake GitLab mode=empty (exit 0, APPROVE, approvals=1 merges=1) — fail-open reproductionsameruninvocation with fake GitLab mode=nonempty (exit 0, APPROVE, approvals=1 merges=1, merge?sha=srcSHA) — positive control/tmp/assent-rvw-s01 lint examples/lint-fixtures/binding-require-empty/bad(exit 1, binding-require-empty)/tmp/assent-rvw-s01 lint examples/lint-fixtures/binding-require-empty/good(exit 0, clean)/tmp/assent-rvw-s01 lint examples/lint-fixtures/reserved-class/good(exit 0, clean)go test ./cmd/assent/ -run 'TestRunEmptyRequireNeverApproves|TestRunApproveArmedMerges' -count=1go test ./internal/lint/ -run 'TestBindingRequireEmpty|TestEveryHardErrorFixtureCaught' -count=1✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.