fix(aos): fail closed when acompose is handed integrated capability flags (#810) #1194

Merged
coilyco-ops merged 2 commits from aos/810-acompose-fail-closed into main 2026-08-22 23:06:55 +00:00
Member

Closes #810.

Reproduced first

$ aos acompose --warded --guarded --composed --role director --agent codex -- codex
aos: start    reclaim finished sessions
aos: start    resolve resident repositories
aos: start    fleet pass over 17 repositories
aos: fetch    1/17 coilyco-bridge/agentic-os-hardware
...

It accepted every flag and went on to fetch seventeen repositories. Ward routing lives only in the root action, and the root flags stay readable after subcommand selection, so nothing objected. The operator gets a standalone container under a command that reads as Ward-brokered.

After

$ aos acompose --warded --guarded --composed --role director --agent codex -- codex
aos: acompose does not honor --warded --guarded --agent: it launches a standalone
AOS container with no Ward lifecycle and no broker boundary.
For the integrated launch, use the root action:
  aos --warded --guarded --composed --role <role> --agent <agent>
For the standalone container, keep the documented form:
  aos --role <role> acompose -- <harness>
$ echo $?
1

The check runs first in runAcompose, ahead of validateLegacyDensity and the role and command checks, because failing closed means before materialization rather than reporting after it. A test asserts that ordering directly, so a later refactor cannot let argument validation mask the refusal.

What is deliberately not refused

  • --composed is a documented no-op on every path ("compatibility flag; AOS always materializes agent-compose role context"), so refusing it would break the standalone form to no effect. The issue's list names --warded, --guarded, --agent, and not this one.
  • acompose-checkin resolves its layout from --agent, so the refusal must not reach it. A test holds that boundary.

Coverage

Seven tests in a new aos-cli/acompose_capability_flags_test.go, covering the acceptance criteria:

  • Flags after the token (the issue's exact repro) and before it, so parser ordering cannot reopen the bypass. Verified against the built binary too: IsSet reads through the lineage in both orders.
  • Each flag alone.
  • The refusal precedes argument validation.
  • --composed alone falls through.
  • acompose-checkin still honors --agent.

The error is asserted to name the offending flags and to show the canonical root shape, since an error that does not point somewhere gets retried in the same shape.

Docs and help

docs/aos-cli.md states the root-action versus legacy-subcommand boundary in the Routing section, and the subcommand's own Usage string now carries it, which is where the mistake is made.

No docs/FEATURES.md entry: this is validation hardening, which that rule names as not earning one.

623 python tests pass, the Go suite passes, pre-commit run --all-files passes.

Closes #810. ## Reproduced first ``` $ aos acompose --warded --guarded --composed --role director --agent codex -- codex aos: start reclaim finished sessions aos: start resolve resident repositories aos: start fleet pass over 17 repositories aos: fetch 1/17 coilyco-bridge/agentic-os-hardware ... ``` It accepted every flag and went on to fetch seventeen repositories. Ward routing lives only in the root action, and the root flags stay readable after subcommand selection, so nothing objected. The operator gets a standalone container under a command that reads as Ward-brokered. ## After ``` $ aos acompose --warded --guarded --composed --role director --agent codex -- codex aos: acompose does not honor --warded --guarded --agent: it launches a standalone AOS container with no Ward lifecycle and no broker boundary. For the integrated launch, use the root action: aos --warded --guarded --composed --role <role> --agent <agent> For the standalone container, keep the documented form: aos --role <role> acompose -- <harness> $ echo $? 1 ``` The check runs **first** in `runAcompose`, ahead of `validateLegacyDensity` and the role and command checks, because failing closed means before materialization rather than reporting after it. A test asserts that ordering directly, so a later refactor cannot let argument validation mask the refusal. ## What is deliberately not refused * **`--composed`** is a documented no-op on every path ("compatibility flag; AOS always materializes agent-compose role context"), so refusing it would break the standalone form to no effect. The issue's list names `--warded`, `--guarded`, `--agent`, and not this one. * **`acompose-checkin`** resolves its layout *from* `--agent`, so the refusal must not reach it. A test holds that boundary. ## Coverage Seven tests in a new `aos-cli/acompose_capability_flags_test.go`, covering the acceptance criteria: * Flags **after** the token (the issue's exact repro) and **before** it, so parser ordering cannot reopen the bypass. Verified against the built binary too: `IsSet` reads through the lineage in both orders. * Each flag alone. * The refusal precedes argument validation. * `--composed` alone falls through. * `acompose-checkin` still honors `--agent`. The error is asserted to name the offending flags **and** to show the canonical root shape, since an error that does not point somewhere gets retried in the same shape. ## Docs and help `docs/aos-cli.md` states the root-action versus legacy-subcommand boundary in the Routing section, and the subcommand's own `Usage` string now carries it, which is where the mistake is made. No `docs/FEATURES.md` entry: this is validation hardening, which that rule names as not earning one. 623 python tests pass, the Go suite passes, `pre-commit run --all-files` passes.
fix(aos): fail closed when acompose is handed integrated capability flags (#810)
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 9s
ci / aos-cli-tests (pull_request) Successful in 24s
ci / ward-doctor (pull_request) Successful in 25s
ci / gate (pull_request) Successful in 1m19s
01b023e353
`aos acompose --warded --guarded --composed --role director --agent codex --
codex` selected the legacy subcommand, dropped the Ward lifecycle and broker
boundary, and ran. Reproduced before changing anything: it accepted the flags
and went on to fetch seventeen repositories, so the operator got a standalone
container under a command that reads as Ward-brokered.

Ward routing lives only in the root action, and the root flags stay readable
after subcommand selection, which is why nothing objected.

runAcompose now refuses --warded, --guarded, and --agent and names the root
launch shape. The check runs first, ahead of validateLegacyDensity and the
role and command checks, because failing closed means before materialization
rather than reporting after it.

--composed is deliberately not refused. It is a documented no-op on every
path, so refusing it would break the standalone form to no effect.

acompose-checkin resolves its layout from --agent, so the refusal does not
reach it, and a test holds that boundary.

Closes #810

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: engineer
Merge branch 'main' into aos/810-acompose-fail-closed
All checks were successful
ci / aos-cli-tests (pull_request) Successful in 23s
ci / aos-eval-tests (pull_request) Successful in 9s
ci / ward-doctor (pull_request) Successful in 14s
ci / gate (pull_request) Successful in 1m4s
7040c979e7
Author
Member

Verified against a binary built from your branch, not against the tests. Every claim in the PR body holds.

go build ./aos-cli from 2b8... (your head), then the exact command from #810:

$ aos acompose --warded --guarded --composed --role director --agent codex -- codex
aos: acompose does not honor --warded --guarded --agent: it launches a standalone
AOS container with no Ward lifecycle and no broker boundary.
...
$ echo $?
1

Exit 1, and nothing ran before it. No reclaim, no residency resolution, no fleet pass. That is the acceptance criterion #810 actually cares about ("fail closed before Docker or Ward starts"), and putting the check ahead of validateLegacyDensity is what buys it. The test that pins the ordering is the right thing to have written, because that property is invisible in the diff a year from now.

Both deliberate non-refusals hold too, which is the half that would have been easy to get wrong:

$ aos --role director acompose --composed -- codex
(no refusal; proceeds into the standalone path)

$ aos acompose-checkin --agent codex ...
(no refusal; exit 0)

cmd.IsSet seeing root-level flags from a subcommand action was the thing I most expected to be wrong, since a false return there would have made the whole guard a silent no-op. It works.

I also confirmed --composed is genuinely a documented no-op rather than a fourth dropped capability, so leaving it out of the refusal list is right rather than an omission.

One follow-up, explicitly not a blocker

The same silent acceptance exists on every other subcommand:

repositories   exit=0, accepts --warded --guarded --agent, ignores them
converge       exit=0, same
version        exit=0, same

Materially this does not matter today. Nobody believes aos version --warded did something Ward-brokered, and converge and repositories are not launches, so there is no wrong execution to be fooled about. acompose was the one where silent acceptance produced a different launch under a command that read as integrated, and you prioritized it correctly.

What is worth a follow-up is the shape rather than the instances: the refusal lives in runAcompose rather than in the flag definitions, so a subcommand added later inherits the hole by default and someone has to remember. If the root capability flags were rejected for any subcommand that does not consume them, this would be structural instead of a list of three strings. That is a bigger change than #810 asked for and I would not fold it in here.

Nothing blocking. Good fix.

**Verified against a binary built from your branch, not against the tests. Every claim in the PR body holds.** `go build ./aos-cli` from `2b8...` (your head), then the exact command from #810: ``` $ aos acompose --warded --guarded --composed --role director --agent codex -- codex aos: acompose does not honor --warded --guarded --agent: it launches a standalone AOS container with no Ward lifecycle and no broker boundary. ... $ echo $? 1 ``` Exit 1, and **nothing ran before it**. No reclaim, no residency resolution, no fleet pass. That is the acceptance criterion #810 actually cares about ("fail closed before Docker or Ward starts"), and putting the check ahead of `validateLegacyDensity` is what buys it. The test that pins the ordering is the right thing to have written, because that property is invisible in the diff a year from now. Both deliberate non-refusals hold too, which is the half that would have been easy to get wrong: ``` $ aos --role director acompose --composed -- codex (no refusal; proceeds into the standalone path) $ aos acompose-checkin --agent codex ... (no refusal; exit 0) ``` `cmd.IsSet` seeing root-level flags from a subcommand action was the thing I most expected to be wrong, since a false return there would have made the whole guard a silent no-op. It works. I also confirmed `--composed` is genuinely a documented no-op rather than a fourth dropped capability, so leaving it out of the refusal list is right rather than an omission. ## One follow-up, explicitly not a blocker The same silent acceptance exists on every other subcommand: ``` repositories exit=0, accepts --warded --guarded --agent, ignores them converge exit=0, same version exit=0, same ``` Materially this does not matter today. Nobody believes `aos version --warded` did something Ward-brokered, and `converge` and `repositories` are not launches, so there is no wrong execution to be fooled about. `acompose` was the one where silent acceptance produced a **different launch** under a command that read as integrated, and you prioritized it correctly. What is worth a follow-up is the shape rather than the instances: the refusal lives in `runAcompose` rather than in the flag definitions, so a subcommand added later inherits the hole by default and someone has to remember. If the root capability flags were rejected for any subcommand that does not consume them, this would be structural instead of a list of three strings. That is a bigger change than #810 asked for and I would not fold it in here. Nothing blocking. Good fix.
coilyco-ops deleted branch aos/810-acompose-fail-closed 2026-08-22 23:06:56 +00:00
Sign in to join this conversation.
No reviewers
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/agentic-os!1194
No description provided.