Fail-closed machine gates - RED when zero gate commands are configured
TASK-CUO-302: Fail-closed machine gates - RED when zero gate commands are configured
Summary
run-gates.sh is the machine-gate floor for every task lifecycle transition, and today it reports GATES: GREEN and exits 0 when not a single gate command is configured. This repo is itself the proof: autodetect returned unknown on the polyglot monorepo, all four floor commands are empty, and the flagship repo gates nothing while reporting green. This task makes the empty floor RED by default, adds an explicit acknowledged-empty escape hatch, teaches autodetect a monorepo fallback so this repo detects its own suite, and stops the gates.env header from inviting edits that the next reinstall wipes.
Problem
Three verified defects compound into audit finding C1 (a core safety promise is false):
- Fail-open floor.
gate()treats an empty command asSKIP(tools/install/gates/run-gates.sh:57), and after the per-gate loop the all-empty case prints an advisory line (GATES: floor only - nothing detected and no overrides, line 75) and then falls through toGATES: GREENwith exit 0 (lines 77-79). Green-is-necessary doctrine (AGENT-ENTRY.md #3) assumes green means something; an all-skip green is vacuous, and both HITL acceptance gates downstream inherit that false confidence. - Autodetect has no monorepo fallback.
install.shprobes package.json scripts and per-ecosystem markers; on this polyglot repo it landsECOSYSTEM=unknownand seeds nothing. The repo has an obvious canonical suite entrypoint (scripts/tests/run_all.sh, 42 suites) that autodetect never looks for. - The header invites edits the machine wipes. The generated
gates.envsays(edit freely)(install.sh:299) while reinstall regenerates the file and moves the old one to a.bak(install.sh:326even says durable overrides belong inconfig.yaml). An operator followed the header's advice; the reinstall wiped a workingTEST_CMDwhich now survives only ingates.env.bak.1784761166.
Proposed Solution
Make the all-empty floor exit RED with a message that names the two real fixes (.cyberos/config.yaml gates.* keys, or re-running install after adding ecosystem markers) and the explicit escape hatch CYBEROS_ALLOW_EMPTY_GATES=1 for repos that genuinely have nothing to run (docs-only repos). The escape hatch prints a loud GATES: EMPTY-ACKNOWLEDGED line so an acknowledged-empty run is never confusable with a green one. Teach the install.sh autodetect an ordered monorepo fallback probe - scripts/tests/run_all.sh first, then Makefile test: target - seeding TEST_CMD with provenance SRC_TEST="fallback:<probe>" so run-gates.sh's existing provenance line shows where the command came from. Reword the generated gates.env header from "edit freely" to "machine-owned; regenerated on every install - durable overrides belong in .cyberos/config.yaml (gates.build/lint/test/coverage)". Ship a CHANGELOG entry marking the RED-on-empty behavior as breaking for consumer repos that relied on floor-only green.
Alternatives Considered
- Warn loudly but stay green on empty. Rejected: that is exactly today's behavior (line 75 already warns); the audit demonstrated the warning changes nothing because green is what the workflow reads.
- Put the acknowledged-empty flag in
.cyberos/config.yaml. Rejected: a config key is set once and outlives its justification silently; an env var must be re-asserted per invocation (or visibly exported in CI), which keeps the acknowledgment honest. The config file remains the home for commands, not for permission to run none. - Hard-require
.cyberos/config.yamlgate keys everywhere. Rejected: breaks zero-config installs on the many repos where autodetect works today (node/python/go/rust/etc. per TASK-CUO-207); the floor should fail only when there is genuinely nothing to run. - Autodetect fallback tries every shell file under
scripts/. Rejected: guessing arbitrary scripts as test commands executes untrusted-shaped code on install; the fallback probes a closed, documented list of canonical entrypoints only (scripts/tests/run_all.sh,Makefilewith atest:target).
Success Metrics
- Primary: by the next CyberOS release, a scratch install onto a repo with no detectable ecosystem followed by
bash .cyberos/cuo/gates/run-gates.shexits non-zero with the RED-empty message, and this repo's own gates run exits 0 only because the monorepo fallback seededTEST_CMD="bash scripts/tests/run_all.sh". Baseline today: both exit 0 with all-empty commands. - Guardrail: zero regressions in the existing gate behavior for configured repos - a repo with at least one configured command keeps today's exact PASS/FAIL/exit semantics (existing suites
test_gates_config.shclass stay green).
Scope
In scope: tools/install/gates/run-gates.sh (RED-on-empty + acknowledged-empty ack line), tools/install/install.sh (fallback autodetect probes + gates.env header rewording), tools/install/README.md (document the new failure mode + escape hatch), CHANGELOG entry, and a new test suite covering all three behaviors.
Out of scope / Non-Goals
- Preserving operator edits to
gates.envacross reinstall (the header fix makes the ownership honest; durable-override preservation across uninstall is TASK-IMP-129's scope). - Any change to the CAF/AWH optional gates (
CAF_ENABLED/AWH_ENABLEDsemantics unchanged; their empty-command handling is unchanged because they are opt-in, not the floor). - The
cyberos doctorgate wiring for memory-installed repos - that is TASK-MEMORY-303's scope. - CI wiring of the gate benchmark (G1) checker beyond this task's own test suite - the benchmark-gates program is TASK-IMP-140.
Dependencies
None blocking. Builds directly on TASK-CUO-207 (done), which shipped the .cyberos/config.yaml gates layer and the per-gate autodetect provenance (SRC_*) this task extends with a fallback: source tier. TASK-IMP-129 (draft) makes config.yaml survive uninstall - complementary, no ordering constraint. TASK-IMP-140's benchmark gate G1 ("Gate-floor non-vacuous") is verified by the test this task ships; listed in related_tasks as a soft forward reference, no cycle.
AI Authorship Disclosure
- Tools used: Claude (Fable 5) running the CyberOS
task-authorskill in Cursor, as the task-authoring wave of the 2026-07-23 hardening plan. - Scope: every
source_pagesline was read at HEAD in this checkout during authoring; the fail-open exit path, the all-empty localgates.env, the header/notice contradiction, and the.bak-only survivingTEST_CMDwere verified first-hand, not carried from the audit report. - Human review: the hardening plan (including this task's scope bullet) was operator-approved on 2026-07-23; the escape-hatch-as-env-var design call is recorded in
source_decisionsfor the reviewer to revisit at the review acceptance gate.
1. Description (normative)
- 1.1
run-gates.shMUST exit with the distinct code 3 (RED, empty floor) when all four floor commands (build, lint, test, coverage) resolve to empty after the config.yaml and gates.env layers are applied, unlessCYBEROS_ALLOW_EMPTY_GATESis set to the literal value1. Exit 3 is deliberately distinct from 1 (a configured gate failed) and 2 (missing/malformed config), so automation can tell "gates ran and failed" from "nothing was configured". Any value other than the literal1(includingtrue,yes,0) MUST behave as unset. The existing per-gateSKIPbehavior for a subset of empty gates is unchanged - the floor fails only when it is entirely vacuous. - 1.2 The RED-on-empty message MUST name both durable fixes (set
gates.build/lint/test/coveragein.cyberos/config.yaml, or re-run install so autodetect can seed commands) and the escape hatch by exact name. A failure an operator cannot act on from the message alone is a support ticket, not a gate. - 1.3 When
CYBEROS_ALLOW_EMPTY_GATES=1is set and the floor is empty,run-gates.shMUST print a distinctGATES: EMPTY-ACKNOWLEDGEDline (notGATES: GREEN) before exiting 0, so logs can never conflate an acknowledged-empty run with a green run. - 1.4
install.shautodetect MUST gain a monorepo fallback tier: when no ecosystem probe seeds a test command, probe an ordered, closed list -scripts/tests/run_all.sh(seedTEST_CMD="bash scripts/tests/run_all.sh"), then aMakefilecontaining atest:target (seedTEST_CMD="make test") - recording provenanceSRC_TEST="fallback:run_all"orSRC_TEST="fallback:make"respectively. The fallback MUST NOT execute the probed files at install time. - 1.5 The generated
gates.envheader MUST NOT say "edit freely"; it MUST state that the file is machine-owned and regenerated on every install, and that durable overrides belong in.cyberos/config.yaml(gates.*keys). The wording change applies to the generator ininstall.sh; installed copies pick it up on next install. - 1.6
CHANGELOG.mdMUST gain an entry documenting RED-on-empty as a breaking behavior change for consumer repos, namingCYBEROS_ALLOW_EMPTY_GATES=1as the migration path for intentionally gate-less repos.
2. Acceptance criteria
- [ ] AC 1 (traces_to: #1.1) - on a scratch install with an all-empty floor,
run-gates.shexits exactly 3; with any one command configured it keeps today's semantics (0 on pass, 1 on gate failure);CYBEROS_ALLOW_EMPTY_GATES=trueand=0still exit 3 - test:tools/install/tests/test_fail_closed_gates.sh::t01_empty_floor_exits_red - [ ] AC 2 (traces_to: #1.2) - the RED output names
.cyberos/config.yaml, re-install, andCYBEROS_ALLOW_EMPTY_GATESall three, asserted as substrings - test:tools/install/tests/test_fail_closed_gates.sh::t02_red_message_actionable - [ ] AC 3 (traces_to: #1.3) - with
CYBEROS_ALLOW_EMPTY_GATES=1and empty floor: exit 0, output containsGATES: EMPTY-ACKNOWLEDGED, and does NOT containGATES: GREEN- test:tools/install/tests/test_fail_closed_gates.sh::t03_ack_line_distinct - [ ] AC 4 (traces_to: #1.4) - installing onto a fixture repo that has
scripts/tests/run_all.shbut no detectable ecosystem seedsTEST_CMD="bash scripts/tests/run_all.sh"withSRC_TEST="fallback:run_all"; the Makefile probe seedsmake teston a Makefile-only fixture; install runs neither probe target - test:tools/install/tests/test_fail_closed_gates.sh::t04_monorepo_fallback_seeds_test_cmd - [ ] AC 5 (traces_to: #1.5) - the generated
gates.envon a scratch install contains noedit freelysubstring and does contain the machine-owned + config.yaml wording - test:tools/install/tests/test_fail_closed_gates.sh::t05_header_machine_owned - [ ] AC 6 (traces_to: #1.6) -
CHANGELOG.md's top entry mentions the RED-on-empty change, the word "breaking", andCYBEROS_ALLOW_EMPTY_GATES- test:tools/install/tests/test_fail_closed_gates.sh::t06_changelog_breaking_entry
3. Edge cases
- CAF/AWH enabled, floor empty: an operator with
CAF_ENABLED=truebut an empty floor still fails 1.1 - the optional gates are additive, not a substitute for the floor. The RED message applies unchanged; acknowledging empty while running CAF-only is expressible via the env var and is the operator's explicit call. - Malformed
.cyberos/config.yaml: already exits 2 before any gate runs (run-gates.shMALFORMED guard); this task's empty-floor check MUST evaluate only after the config layer parsed, so the two failure modes keep distinct exit paths and messages. - Docs-only consumer repo: legitimately has nothing to run; the documented path is exporting
CYBEROS_ALLOW_EMPTY_GATES=1(e.g. in the repo's CI env). The ack line keeps the honesty visible in every log. gates.envpredating this change: an installed repo that upgrades its payload but has not re-run install still has the old header text; the RED-on-empty behavior comes from the vendoredrun-gates.sh(rebuilt payload) regardless, so enforcement does not wait on the header fix. Stale-header-only trees are acceptable; behavior is not.- Fallback probe on a repo with BOTH
run_all.shand a Makefile: the ordered list wins -run_all.shseeds and the Makefile is not consulted; a fixture asserts the precedence so the order is contractual, not incidental. - Security-class: the fallback seeds a command string into
gates.envbut never executes it at install time (1.4); execution happens only insiderun-gates.shunder the operator's existingevaltrust model for gate commands. No new execution surface is introduced at install time.
Audit
§1 — Verdict summary
Six §1 clauses, six ACs, six edge cases including a security-class row. Every clause traces 1:1 to an AC via traces_to; every factual claim in Problem/source_pages was re-verified against the working tree during authoring (fail-open exit path read in source, all-empty local gates.env confirmed, header/regen-notice contradiction confirmed at install.sh:299 vs :326). The two consequential findings were an unpinned exit code and undefined env-var value semantics — both would have shipped ambiguity into the exact automation surface (G1 checker) this task exists to serve.
§2 — Findings (all resolved)
ISS-001 — RED-on-empty exit code was unpinned (spec said only "non-zero")
run-gates.sh already uses exit 1 (gate failure) and exit 2 (missing gates.env / malformed config.yaml). A bare "non-zero" for the empty floor would collide with both, and TASK-IMP-140's G1 checker needs to distinguish "gates ran and failed" from "nothing configured" mechanically. Resolved: clause 1.1 pins exit 3 and names why it is distinct; AC 1 asserts the exact code.
ISS-002 — escape-hatch value semantics were undefined (TRACE-006-adjacent)
"CYBEROS_ALLOW_EMPTY_GATES=1 is set" left =true, =yes, =0 undefined — an operator exporting =true would get RED and reasonably call it a bug, or worse, a sloppy implementation would accept any non-empty value and =0 would acknowledge-empty. Resolved: clause 1.1 requires the literal 1 and requires every other value to behave as unset; AC 1 asserts =true and =0 still exit 3.
ISS-003 — non-execution of fallback probes needed an observable test method
Clause 1.4 demands the fallback MUST NOT execute probe targets at install time, but the first AC draft asserted only that the command string was seeded — a test that cannot see execution cannot verify a MUST NOT. Resolved: AC 4 asserts "install runs neither probe target", verifiable via sentinel fixtures (probe scripts that write a marker file when executed; the test asserts the marker is absent).
ISS-004 — acknowledged-empty was distinguishable only by exit code in the first draft
If the ack path printed the normal GATES: GREEN line, log readers (and the ship-tasks transcript) could not tell an acknowledged-empty run from a real green run — the exact conflation C1 is about. Resolved: clause 1.3 requires the distinct GATES: EMPTY-ACKNOWLEDGED line AND the absence of GATES: GREEN; AC 3 asserts both halves.
ISS-005 — fallback probe order was stated but not contractual
With both scripts/tests/run_all.sh and a Makefile present, the seeded command depended on implementation order. Resolved: clause 1.4 fixes the ordered, closed probe list; the edge-case section adds the both-present fixture; AC 4 asserts run_all wins.
ISS-006 — stale installed trees (old header text) vs new behavior needed an explicit posture
An upgraded payload enforces RED-on-empty while the consumer's gates.env still carries the old "edit freely" header until the next install — a reader could conclude enforcement waits on the header. Resolved: edge case states behavior ships with the vendored run-gates.sh regardless of header vintage; stale-header-only trees are acceptable, stale behavior is not.
§3 — TRACE-006 semantic sufficiency (per clause)
| Clause | Verb demand | Cited test asserts | Verdict |
|---|---|---|---|
| 1.1 MUST exit 3 on empty floor; other env values behave as unset | exact exit code on empty; unchanged semantics when configured; literal-1 gate | AC 1: exit==3 empty, ==0/==1 configured, =true/=0 still 3 | sufficient |
| 1.2 MUST name both fixes + hatch | three actionable substrings present in RED output | AC 2: asserts all three substrings | sufficient |
| 1.3 MUST print distinct ack line, not GREEN | positive line present AND green line absent | AC 3: asserts both halves | sufficient |
| 1.4 MUST seed fallback with provenance; MUST NOT execute | seeded value + SRC_TEST provenance + non-execution + precedence | AC 4: asserts seed, provenance, sentinel non-execution, run_all-beats-Makefile | sufficient after revision (ISS-003, ISS-005) |
| 1.5 MUST NOT say "edit freely"; MUST state machine-owned | negative substring + positive replacement in generated file | AC 5: asserts both halves against a scratch install | sufficient |
| 1.6 MUST gain CHANGELOG entry | positive content present in top entry | AC 6: asserts three required substrings | sufficient |
§4 — Resolution
Six findings, all material, all resolved in the audited revision. Score = 10/10.
Status transition draft -> ready_to_implement is authorised by this verdict per STATUS-REFERENCE.md §1.1. The two human-acceptance gates in /ship-tasks (review acceptance, final acceptance) are unchanged and remain recorded human verdicts — this audit clears the spec-correctness gate only.
End of TASK-CUO-302 audit.