Skip to content

Make gated unattended reviewers reachable: consent on supervised daemons, a withheld diagnostic, and an affirmation-based Gemini gate - #473

Merged
realtonyyoung merged 5 commits into
mainfrom
tyoung/gemini-reviewer-consent-wiring
Aug 6, 2026
Merged

realtonyyoung merged 5 commits into
mainfrom
tyoung/gemini-reviewer-consent-wiring

Conversation

@realtonyyoung

@realtonyyoung realtonyyoung commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #472. Linear: AI-1755.

The reported symptom

start_review_flow(vendor: "gemini") fails with reviewer_vendor_unavailable on a daemon where Gemini is installed, on PATH, and hosts interactively fine.

The filed diagnosis pointed at CliResolver.Exists. That was wrong — Gemini resolves and is in SupportedVendors. Advertisement filters on the factory's SupportsUnattended, which the ACP factory overrides, and for the gated reviewers that override also requires operator consent and an accepted vendor build. Three things were wrong behind it.

1. A supervised daemon could never be given consent

KCAP_GEMINI_UNATTENDED_REVIEWER / KCAP_KIRO_UNATTENDED_REVIEWER are read from the daemon's own environment and have no config-file or profile binding. ServiceEnvironment did not carry them into a service unit, and launchd/systemd/a scheduled task inherit nothing from the installing shell — so exporting a flag and reinstalling produced a unit that silently did not have it. Same "shipped but unreachable" shape the env bindings themselves were added to fix, just moved from the daemon to the unit, and it lands on the normal way to run a daemon.

Both flags are now carried on every platform (booleans an operator typed, not secret-capable, so GoogleSecretCapableKeys' platform exclusion does not apply). Capture never creates an opt-in — a unit can only end up with a flag the installing environment already had — but it does freeze one, so install prints a Consent: line naming each flag it captured, read from the built environment rather than the ambient one.

2. A withheld reviewer never said why

DenialReason is precise, but only the launch path threw it — and advertisement is exactly what stops the launch being attempted. The one artifact that explains the refusal could never be produced; the only route to the answer was reading the daemon source, which is how this was diagnosed.

Startup now logs one line per vendor that is installed and unattended-capable but withheld, carrying that same text. A vendor that simply does not offer unattended hosting is deliberately not reported. It logs at Information, not Warning — a gated vendor installed with no opt-in is a normal steady state, and warning on a correct configuration at every restart is how a diagnostic gets ignored.

3. The version gate made the feature unreachable in the first place

Gemini was gated on CertifiedVersions — a maintainer-curated set ({"0.53.0"}) of builds whose MCP allowlist had been read. The reporting machine ran 0.54.0, one patch ahead, so even with 1 and 2 fixed the reviewer stayed refused until a new kcap shipped. Against a CLI that moves several versions a week that repeats on every release: the gate was not protecting the feature, it was preventing it.

Kiro already solved this one commit earlier — fail closed when the installed build changes, cleared by the operator via kcap daemon reviewer affirm. Gemini now uses that model and that command (--vendor gemini).

Generalized rather than copied: the store is vendor-keyed and the version comparison lives in one place, so the two reviewers cannot drift on what "the operator accepted this build" means, while each keeps its own preconditions (Kiro's POSIX requirement) and its own consent text. Kiro's on-disk filename is deliberately unchanged — renaming it would have made every existing affirmation invisible and silently withheld a shipped reviewer on upgrade, which is pinned by a test.

The trade is stated in the type doc rather than buried: the certified set asserted a maintainer had read that build's matcher; an affirmation asserts only that the operator accepted the build. That is the weaker claim, made by the party who carries the risk on the machine that carries it. A minimum-version floor was considered and rejected — it assumes the allowlist's semantics can only improve, which is an assumption about someone else's code, and would silently admit a build that changed matching to prefix or flipped empty-list semantics.

One decision, one probe

The advertisement flag and the reason come from a single DescribeUnattendedSupport() call, because answering spawns the vendor binary to read its version. Startup needs the advertised list, the capability list and the diagnostic; deriving all three from one classification also removes a duplicate probe that was already there. GateInputsFor does the same on the refusal path, where the decision and its explanation both needed the installed version and resolved it twice.

That collapses a real duplication: advertisement and the launch boundary were separately maintained copies of one gate ladder — which is how they could disagree about whether a reviewer was available while only one could say why. There is now one ReviewerRefusal, deliberately uncached so the launch boundary re-judges a build swapped under a running daemon.

Behaviour changes worth calling out

  • gemini_unattended_reviewer_version_uncertified / _unresolved → gemini_reviewer_version_unaffirmed / _unresolved, matching Kiro's shape now that the two share a model. gemini_unattended_reviewer_disabled is unchanged — though its text no longer tells operators to set GeminiUnattendedReviewerEnabled, a thing none of them could set.
  • KiroReviewerVersionStore → ReviewerVersionStore(stateDir, vendor). On-disk filename for kiro is unchanged.
  • The live certification stays, and now certifies the mechanism against a build rather than backing a version list.

Testing

Suites green: Gemini* 154, Kiro* 129, Acp* 378, Daemon* 231, Service* 36, Reviewer* 104. Clean AOT publish, no IL warnings.

Every new guard was mutation-tested rather than assumed, each mutant reverted by its inverse and re-run green:

Mutant Result
Drop ReviewerConsentKeys from the unit environment 3 failures
Suppress the withheld reason 2 failures
Make AdvertisedUnattendedVendors filter on the reason instead of Advertised 2 failures
Collapse FileNameFor to a vendor-independent name 3 failures
Ignore the operator consent flag 2 failures

New tests carry their own negative controls — an advertised vendor must report no reason, a vendor that never offered unattended hosting must not be reported as withheld, and the affirmed build must permit a launch — so a gate that refused everything, or a reason that was always populated, fails.

Reviews

Copilot review flow: clean (two rounds on the consent/diagnostic work, one round on the gate rework). It caught the Warning-vs-Information level, a sentence seam, and a test that compared a composition to its own definition — all fixed. Qodo: threads addressed and resolved.

… and say why one is withheld

Two compounding problems kept the Gemini reviewer unreachable and undiagnosable.

A supervised daemon could never be given consent. The two reviewer consent flags are
read from the daemon's own environment and have no config-file or profile binding, but
the service unit never carried them — and launchd/systemd/a scheduled task inherit
nothing from the installing shell. Exporting a flag and reinstalling produced a unit
that silently did not have it. ServiceEnvironment now carries both (booleans, not
secret-capable, so no platform exclusion applies), and install reports each one it
captured: the unit outlives the shell it came from, so freezing a consent decision is
something the operator watches happen rather than discovers later.

A withheld reviewer never said why. The gate's denial text is precise, but only the
launch path threw it — and advertisement is exactly what stops the launch from being
attempted, so the one artifact that explains the refusal could never be produced. The
only remaining route to the answer was reading the daemon source. Startup now logs one
Warning per vendor that is installed and unattended-capable but withheld, carrying that
same text. A vendor that simply does not offer unattended hosting is deliberately not
reported: warning about a design fact would train operators to ignore the actionable one.

Both facts now come from one call. The advertisement flag and the reason are resolved
together, because answering spawns the vendor binary to read its version — and startup
needs the advertised list, the capability list and the diagnostic. Deriving all three
from one classification also removes an existing duplicate probe.

That collapses a real duplication on the way: advertisement and the launch boundary were
separately maintained copies of the same gate ladder, which is how they could disagree
about whether a reviewer was available while only one of them could say why. There is now
one function; the launch boundary still evaluates it live, so a build swapped under a
running daemon is re-judged rather than read from a startup snapshot.

An uncertified vendor build stays refused — that is the fail-closed design working, and
clearing it remains a maintainer act. This only makes the refusal legible.

Closes #472
AI-1755
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Carry reviewer consent flags into service units; log why a reviewer is withheld

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Fixes reviewer_vendor_unavailable for supervised daemons: ServiceEnvironment now carries
 KCAP_GEMINI_UNATTENDED_REVIEWER/KCAP_KIRO_UNATTENDED_REVIEWER into the service unit, since units
 inherit nothing from the installing shell.
• kcap daemon service install now prints a Consent: line for each reviewer flag actually
 captured into the unit.
• Adds DescribeUnattendedSupport() so a single probe yields both the advertisement flag and the
 denial reason, unifying the previously separate advertisement and launch-time gate logic.
• Daemon startup now logs a Warning for each installed, unattended-capable vendor that is being
 withheld, explaining why — vendors that never support unattended hosting are not logged.
• Documents the new env-carrying behavior, the Consent: line, and the diagnostic log grep in
 README.
Diagram

graph TD
  Runner["DaemonRunner startup"] --> Classify["ClassifyUnattendedVendors"] --> Describe["Factory.DescribeUnattendedSupport"]
  Describe --> Refusal["ReviewerRefusal gate ladder"]
  Classify --> Advertise["UnattendedVendors list"]
  Classify --> WarnLog["Withheld vendor Warning log"]
  Launch["Launch path RequireReviewerCapability"] --> Refusal
  Install["kcap daemon service install"] --> Build["ServiceEnvironment.Build"] --> Unit[(Service unit env)]
  Build --> Carried["CarriedConsentFlags"] --> ConsentMsg["Consent: line printed"]
  subgraph Legend
    direction LR
    _svc([Process/Function]) ~~~ _db[(Stored env)]
  end
Loading
High-Level Assessment

Consolidating the advertisement flag and denial reason behind a single DescribeUnattendedSupport() call, and routing both the launch-time gate and startup diagnostic through one ReviewerRefusal function, is the correct fix: it eliminates the risk of the two code paths drifting apart and avoids double-probing a slow vendor binary. Carrying the consent flags through the existing ServiceEnvironment.Build mechanism (rather than inventing a new config-file binding) is consistent with how other env-only settings are already handled for services.

Files changed (10) +501 / -48

Enhancement (4) +149 / -44
DaemonRunner.csClassify unattended vendors once and log withheld-reviewer warnings +63/-13

Classify unattended vendors once and log withheld-reviewer warnings

• Introduces ClassifyUnattendedVendors/UnattendedVendorStatus to compute advertised vendors and withheld reasons from a single DescribeUnattendedSupport pass, refactors ComputeUnattendedVendors/ComputeUnattendedVendorCapabilities to reuse that classification, and adds a startup Warning log for each installed vendor withheld by a daemon-local gate.

src/Capacitor.Cli.Daemon/DaemonRunner.cs

AcpHostedAgentRuntimeFactory.csUnify advertisement and launch-time gate via DescribeUnattendedSupport/ReviewerRefusal +54/-31

Unify advertisement and launch-time gate via DescribeUnattendedSupport/ReviewerRefusal

• Replaces the SupportsUnattended getter's inline gate logic with DescribeUnattendedSupport(), and extracts the shared gate ladder into ReviewerRefusal so both advertisement and RequireReviewerCapability read the same denial reason, preventing the two from drifting apart.

src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs

IHostedAgentRuntimeFactory.csAdd UnattendedSupport record and DescribeUnattendedSupport interface member +23/-0

Add UnattendedSupport record and DescribeUnattendedSupport interface member

• Defines the UnattendedSupport(bool Supported, string? WithheldReason) record struct and a default-implemented DescribeUnattendedSupport() method on the factory interface for combined advertisement/reason reporting.

src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs

DaemonCommands.csPrint Consent: lines for captured reviewer flags on service install +9/-0

Print Consent: lines for captured reviewer flags on service install

• After installing a service, iterates ServiceEnvironment.CarriedConsentFlags(env) and prints a line naming each reviewer consent flag frozen into the unit.

src/Capacitor.Cli/Commands/DaemonCommands.cs

Bug fix (1) +34 / -2
ServiceEnvironment.csCarry reviewer consent flags into service units on every platform +34/-2

Carry reviewer consent flags into service units on every platform

• Adds ReviewerConsentKeys (Gemini/Kiro unattended reviewer flags) to the keys carried by Build on both Windows and non-Windows, and adds CarriedConsentFlags to report which flags were actually written into the built environment.

src/Capacitor.Cli/Services/ServiceEnvironment.cs

Tests (3) +282 / -0
DaemonRunnerCursorAvailabilityTests.csAdd tests for ClassifyUnattendedVendors and withheld-reason propagation +113/-0

Add tests for ClassifyUnattendedVendors and withheld-reason propagation

• Adds a WithholdingRuntimeFactory test double and tests verifying classification carries withheld reasons, skips unavailable vendors, probes each factory exactly once, and agrees with the legacy ComputeUnattendedVendors helper.

test/Capacitor.Cli.Tests.Unit/Daemon/DaemonRunnerCursorAvailabilityTests.cs

AcpHostedAgentRuntimeFactoryTests.csAdd tests for DescribeUnattendedSupport withheld-reason behavior +110/-0

Add tests for DescribeUnattendedSupport withheld-reason behavior

• Adds tests covering disabled-daemon, uncertified-version, advertised-vendor, and never-unattended-vendor cases for DescribeUnattendedSupport, plus a test asserting the vendor binary is probed exactly once.

test/Capacitor.Cli.Tests.Unit/Services/AcpHostedAgentRuntimeFactoryTests.cs

ServiceEnvironmentTests.csAdd tests for consent flag carrying and CarriedConsentFlags reporting +59/-0

Add tests for consent flag carrying and CarriedConsentFlags reporting

• Adds tests verifying both reviewer consent flags are carried on every platform, never invented when absent, and that CarriedConsentFlags reports only flags actually present in the built environment (not blank/dropped ones).

test/Capacitor.Cli.Tests.Unit/Services/ServiceEnvironmentTests.cs

Documentation (2) +36 / -2
README.mdDocument consent-flag service capture and withheld-reviewer diagnostic +28/-1

Document consent-flag service capture and withheld-reviewer diagnostic

• Adds a section explaining that reviewer consent flags must be exported before service install, describes the new Consent: install output, and documents grepping daemon logs for withheld-reviewer warnings. Also updates KCAP_GEMINI_PATH/KCAP_COPILOT_PATH docs to reflect Gemini now being hosted and used by the unattended reviewer.

README.md

DaemonConfig.csExpand doc comments on env-bound reviewer/path config fields +8/-1

Expand doc comments on env-bound reviewer/path config fields

• Documents that GeminiUnattendedReviewerEnabled must be set via the daemon's (service unit's) environment, and that GeminiPath now drives both interactive hosting and the gated unattended reviewer probe.

src/Capacitor.Cli.Daemon/DaemonConfig.cs

@qodo-code-review

qodo-code-review Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Verbose rationale comment blocks ✓ Resolved 📘 Rule violation ⚙ Maintainability DosAndDonts-CommentsNotVerbose
Description
New multi-paragraph rationale comments were added inline, increasing noise and maintenance burden in
core code paths. This violates the guideline to keep comments concise and prefer self-explanatory
code.
Code

src/Capacitor.Cli/Services/ServiceEnvironment.cs[R20-23]

+    /// <para><b>Why they have to be here.</b> A supervised daemon inherits nothing from the installing
+    /// shell, and these flags have no profile or config-file binding — the daemon reads them from its
+    /// own environment and nowhere else. Without this, exporting one and reinstalling the service
+    /// produced a unit that silently did not carry it, so the reviewer stayed off with no error and no
Evidence
PR Compliance ID 5 requires concise comments; the cited regions add long, explanatory narratives
(multiple paragraphs) describing behavior and historical rationale inline, rather than keeping
comments minimal.

Rule DosAndDonts-CommentsNotVerbose: Avoid Overly Verbose Comments; Prefer Self-Explanatory Code
src/Capacitor.Cli/Services/ServiceEnvironment.cs[17-35]
src/Capacitor.Cli.Daemon/DaemonRunner.cs[472-478]
src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs[819-833]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several new comments/documentation blocks are very verbose (multi-paragraph, repeating context and rationale inline), which reduces readability and makes future maintenance harder.

## Issue Context
The compliance checklist requires keeping comments minimal and preferring self-explanatory code over extensive narrative in-source commentary.

## Fix Focus Areas
- src/Capacitor.Cli/Services/ServiceEnvironment.cs[17-35]
- src/Capacitor.Cli.Daemon/DaemonRunner.cs[472-478]
- src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs[819-833]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Misattached XML docs ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
In IHostedAgentRuntimeFactory.cs, the interface-level doc comment now precedes UnattendedSupport, so
it documents the record instead of IHostedAgentRuntimeFactory and leaves the interface without its
intended summary. This produces misleading generated docs and may cause noise in builds/tooling that
validates XML documentation.
Code

src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs[R17-20]

+/// <summary>
+/// A runtime's unattended-hosting advertisement: whether it is offered, and — when a daemon-local
+/// gate is what withholds it — the operator-actionable reason.
+/// </summary>
Evidence
The file shows two contiguous <summary> blocks before UnattendedSupport, so the earlier
interface description is now attached to the record instead of the interface that follows later.

src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs[9-27]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs` now has two consecutive `/// <summary>` blocks immediately before `UnattendedSupport`, and the original interface summary is no longer attached to `IHostedAgentRuntimeFactory`. In C#, contiguous `///` comments attach to the *next* declaration, so this mis-documents `UnattendedSupport` and strips the interface’s intended summary.

### Issue Context
This is a PR-introduced documentation association issue (not runtime behavior), but it makes the codebase harder to maintain and can confuse anyone reading generated docs.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs[9-27]

### Suggested fix
- Ensure the “Runtime-selection seam…” summary comment sits immediately above `internal interface IHostedAgentRuntimeFactory`.
- Keep exactly one appropriate `<summary>` block for `UnattendedSupport` (remove the duplicated/incorrect one or merge content as needed).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

The withheld-vendor line drops from Warning to Information. A daemon with a
gated vendor installed and no opt-in is in a perfectly normal steady state — an
operator may run Kiro or Gemini interactively and never intend either
unattended — so at Warning this fired on every restart, forever, for a correct
configuration, and in most stacks Warning is what reaches an alerting pipeline.
The daemon's default minimum level is Information, so the line is still in the
log for whoever goes looking, which is all it was ever for.

The reason now ends the message. Every reason string is a full sentence ending
in a period, so interpolating it mid-sentence rendered "…acceptable. Until that
is resolved…", which reads like a truncation artifact.

AdvertisedUnattendedVendors is now tested on its own inputs. Since
ComputeUnattendedVendors is defined AS that composition, the old test compared a
composition to its own definition and could not have failed for either function
independently. Feeding statuses directly tests the one thing the helper does,
including that it reads Advertised rather than the reason field — verified by
mutating the predicate, which the previous version did not catch.
Qodo flagged the new multi-paragraph blocks against the repo's own
DosAndDonts-CommentsNotVerbose rule, and it is right that they had grown past
what the facts need. Each one keeps the non-obvious claim — why one call rather
than two, why the ladder is not cached, why Information rather than Warning, why
capture cannot create consent — and drops the restatement around it.
@linear-code

linear-code Bot commented Aug 6, 2026

Copy link
Copy Markdown

AI-1755

…rsion set

The certified set was a maintainer-curated list of gemini builds whose MCP
allowlist had been read. It failed in the way that shape fails: the reviewer
went offline at 0.54.0, one patch ahead of the certified 0.53.0, and could only
come back by shipping a new kcap. Every Gemini release repeats that, against a
CLI that moves several versions a week — so in practice the feature was
unreachable rather than protected.

Kiro already faced this and answered it. Its gate fails closed when the
installed build CHANGES and is cleared by the operator — the party who already
carries the risk — with no release involved. Gemini now uses the same model and
the same command, `kcap daemon reviewer affirm --vendor gemini`.

Generalized rather than copied. The store is vendor-keyed and the version
comparison lives in one place, so the two reviewers cannot drift apart on what
"the operator accepted this build" means; each keeps its own preconditions
(Kiro's POSIX requirement) and its own consent text, because what enabling a
reviewer grants differs per vendor. Kiro's on-disk filename is deliberately
unchanged — renaming it would have made every existing affirmation invisible and
silently withheld shipped reviewers on upgrade, which is pinned by a test.

What the trade is, stated rather than buried: the certified set asserted a
maintainer had read that build's matcher; an affirmation asserts only that the
operator accepted the build. That is the weaker claim, made by the party who
carries the risk on the machine that carries it. A minimum-version floor was
considered and rejected — it would assume the allowlist's semantics can only
improve, which is an assumption about someone else's code, and would silently
admit a build that changed matching to prefix or flipped empty-list semantics.

Gemini's version refusal codes are renamed to match Kiro's
(`gemini_reviewer_version_unresolved` / `_unaffirmed`), since the two now share
one model. The live certification stays, and now certifies the MECHANISM against
a build rather than backing a version list.

Also collapses a duplicate probe on the refusal path: the decision and its
explanation both need the installed version, and resolving it per consumer
spawned the vendor binary twice to produce one refusal.
@realtonyyoung realtonyyoung changed the title Make gated unattended reviewers reachable on a supervised daemon, and say why one is withheld Make gated unattended reviewers reachable: consent on supervised daemons, a withheld diagnostic, and an affirmation-based Gemini gate Aug 6, 2026
@realtonyyoung
realtonyyoung merged commit 2df15b6 into main Aug 6, 2026
6 checks passed
@realtonyyoung
realtonyyoung deleted the tyoung/gemini-reviewer-consent-wiring branch August 6, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unattended reviewer can't be enabled on a supervised daemon, and a withheld reviewer never says why

1 participant