Skip to content

[AI-899] Host Gemini CLI as an ACP hosted agent - #421

Merged
realtonyyoung merged 16 commits into
mainfrom
tonyyoung/ai-899-gemini-hosting
Jul 31, 2026
Merged

realtonyyoung merged 16 commits into
mainfrom
tonyyoung/ai-899-gemini-hosting

Conversation

@realtonyyoung

@realtonyyoung realtonyyoung commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Gemini CLI as the fifth ACP hosted-agent vendor, after Cursor, Copilot, Claude/Codex and Kiro. Interactive hosting only — the unattended reviewer is AI-1413, withheld here for the same structural reason it was for Kiro.

Spec: docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md (rev 6, in this branch). Two codex spec-review rounds (9 findings, then 7) plus a gating security probe before implementation; then eight codex code-review rounds, clean at round 8. Every finding was real, and rev 6 of the spec records the two where rev 5's design was wrong rather than its prose — §3.1d and §4.2b.

What ships

Descriptor --experimental-acp --skip-trust --allowed-mcp-server-names <per-launch unguessable>, SupportsUnattended: false, NoOpModelSelector, SupportsMcpServers: false
Registration one AcpHostedAgentRuntimeFactory line in DaemonRunner
Env capture the Google/Vertex configuration into the service unit, with a platform split
Sink guard all four unit writers guard every value they interpolate, not just environment values
Diagnostic a Gemini DiagnosticAuthHint that suggests rather than diagnoses
Docs README's daemon-environment section

--skip-trust is required, and it is NOT containment

Gemini refuses a headless turn in an untrusted directory outright — exit 55, before any model call — and a daemon worktree cannot be assumed pre-trusted, so without the flag every launch fails.

What it does not do is protect anything. Measured: trust INHERITS from a trusted parent directory, and daemon worktrees live at <repo>/.capacitor/worktrees/agent-…, inside the operator's repository. An operator who has trusted their own repo gives every hosted Gemini agent inherited trust over the checked-out branch's configuration, and passing --skip-trust does not withdraw it.

Under that inherited trust, a repository-authored .gemini/settings.json MCP server was observed starting on the ACP path: repo-controlled process execution under the daemon user, before the model acts, on a branch that may be contributor-authored. That is why the MCP allowlist is in the argv — reduced to a single non-matching name, it permits nothing and blocks it. Verified: repo-authored server blocked, an injected server still loads.

The name must be generated, not written down (§3.1d). The first version used the literal kcap-none with a comment calling it "a name no MCP server will ever have" — nothing enforced that, and measured, a repository naming its server kcap-none executes. This is a public repo, so the constant is the bypass. The descriptor now carries a placeholder that AcpHostedAgentRuntimeFactory replaces per launch with kcap-deny-{guid}. Verified in both directions: a repo naming its server the placeholder itself is blocked, and an ACP-injected server still loads.

Repo-authored hooks were separately measured not to run on the ACP path, though they do on the --prompt path. The two paths differ and neither predicts the other — an earlier revision of this spec concluded from the --prompt path that hooks were an unclampable blocker, and was wrong.

The allowlist contents are coupled to SupportsMcpServers

A non-matching name permits nothing, which is correct only while nothing is injected. The day the stdio call-level probe flips SupportsMcpServers to true, the list must become the injected server names in the same change, or hosted Gemini ships with MCP silently broken. Gemini_McpAllowlist_IsCoupledToSupportsMcpServers asserts both directions, and it is mutation-proven: flipping the flag alone fails exactly it plus the constants test.

(--allowed-mcp-server-names "" is not usable — it crashes Gemini's config load before session start, which also makes it a trap when verifying by hand.)

The daemon does not see your shell

A supervised daemon inherits nothing from an interactive shell, so export GOOGLE_CLOUD_PROJECT=… in .zshrc is invisible to a hosted Gemini agent — and Gemini reports the absence as IneligibleTierError: … no longer supported for Gemini Code Assist for individuals, thrown by a function named throwIneligibleOrProjectIdError, which emits the same text for a missing project id. That message cost this investigation two retracted conclusions.

Both project spellings are carried, because Gemini's own error says "The GOOGLE_CLOUD_PROJECT (or GOOGLE_CLOUD_PROJECT_ID) environment variable must be set".

Platform split, as a principle rather than per variable: persist a value into a unit only when there is no non-persistent alternative and the platform gives an owner-only guarantee this code actually enforces.

Everywhere macOS/Linux only Never
GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_LOCATION, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_GENAI_USE_GCA GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_GEMINI_BASE_URL, GOOGLE_VERTEX_BASE_URL GOOGLE_API_KEY, GOOGLE_CREDENTIALS

The middle column is secret-capable: a credential path discloses where the credential is, and a URL can carry userinfo or a query token. On Unix that is bounded by a guarantee this code enforces — ServiceFiles writes 0600, re-checks the mode on the open handle (umask filters UnixCreateMode, so the request is not the result), and refuses a group/other-writable directory. Every one of those paths returns early on Windows ("ACL-governed, inherited from the user profile"), so there is no equivalent guarantee and those three are excluded there — exactly as KCAP_COPILOT_TOKEN_CMD is.

The pre-existing holes closed on the way — this is most of the diff

Carrying eight new variables into a service unit meant reading what the unit writers actually do with a value. They were unsafe in ways that had nothing to do with Gemini, so this closes them rather than stepping around them. Review found six of these; the pattern is one thing, repeated.

The check is at the sink, deliberately, not at ServiceEnvironment.Build. Build is not a boundary: DaemonCommands adds KCAP_DAEMON_SUPERVISED to the dictionary after Capture returns, so a value already reaches the writers without passing through it. The tests call the writers directly for the same reason — routing through Build would pass even if a writer were defenceless — and one case arrives by that exact bypass route.

Some values guarded, their neighbours not

The unit of the bug is the artifact, not the value. Every writer interpolates several values into one line or file, and each had a subset covered:

sink was guarded was not, on the same line/file
Windows .cmd exec line binary path log path, every ExtraArgs token
Windows set "K=V" the value the key — a quote in a key closes the assignment identically
systemd Environment= the value the key, which can inject an ExecStartPre= that runs on every restart
systemd ExecStart= nothing binary path, log path, every argument
launchd plist environment values the label, ProgramArguments, StandardOut/ErrorPath
Task Scheduler XML nothing the wrapper path, the service id

Each sink now has one guard-then-escape helper that every interpolation goes through — applied even to values provably safe today, so the next value added inherits it.

Escaping one expansion without asking what else the format expands

CmdValue doubling % was correct, and it stopped me looking. Both formats run more than one pass, and quoting is not a boundary in either:

  • cmd treats & | < > ( ) ^ as metacharacters outside quotes, so quote-on-space emitted 8&calc.exe bare into a file the OS runs at every logon → every exec-line value is now quoted unconditionally. !NAME! delayed expansion works inside quotes and is registry-enablable machine-wide, so the execution mode is now part of the artifact (setlocal DisableDelayedExpansion + /v:off). And cmd /c "<path>" relied on conditional quote-stripping that a path containing & defeats → the nested-quote /d /s /v:off /c ""<path>"" form (/d also skips AutoRun).
  • systemd expands % specifiers and $NAME/${NAME} in ExecStart=, so a path under /home/50%off or containing ${HOME} was rewritten in the executable position. Both are doubled and reversed by BinaryFromUnit. $ is deliberately not doubled in Environment=, where systemd expands specifiers but not variables. The apostrophe is as structural to systemd's word lexer as the double quote and was missing from NeedsQuote.

Escape where verifiable; otherwise refuse

Nothing here can exercise a real systemd or cmd parser. So where the documented escape is unambiguous (%%, $$, /s nested quotes, /v:off) it is implemented and the tests assert the rendered text plus the round-trip. Where it is not — systemd's \; for a literal semicolon — the input is refused: a bare ; is systemd's command separator, and a refusal cannot be subtly wrong. Same for all C0/DEL control characters at both systemd sinks, and for % in the path handed to cmd /c, where %% simply does not exist as an escape.

That last point is worth naming: the same character needs different treatment at adjacent sinks. %% is right in the wrapper's batch-file body and wrong on the Task XML command line. A per-character policy gets one of the two wrong.

SystemdValue's old CR/LF→space rewrite is gone for the same reason: a service running with a value nobody chose is harder to diagnose than an install that failed and named the variable.

And the XML predicate was not the XML predicate

char.IsControl differs from XML 1.0 in both directions — it accepts U+FFFE/U+FFFF and lone surrogates (which no encoder can represent) and rejects U+007F–U+009F (which XML 1.0 permits). Replaced with XmlConvert.IsXmlChar plus surrogate-pair awareness, so an emoji in a path is accepted and a malformed surrogate is not.

One fix at the source, not just the sink

--max-agents is the only caller-supplied ExtraArgs entry, arrives unparsed off the command line, and is numeric — so kcap daemon service install --max-agents "8&calc.exe" was persisting that string into a logon-executed file. It is now validated as a positive integer where it enters, independently of the sink hardening.

The diagnostic suggests, it does not diagnose

GeminiAuthHint carries two hedges and an explicit "or it may be unrelated", because the failure it attaches to often is. Reproducing Gemini's own confidently-wrong attribution would be worse than saying nothing. Asserted clause by clause rather than by keyword scan.

Verification

Suite Result
AcpVendorDescriptorTests 19/19
AcpHostedAgentRuntimeFactoryTests 68/68
UnitWriterExpansionTests (new) 74/74
XmlRepresentableValueTests (new) 26/26
UnitWriterEnvNameTests (new) 24/24
WindowsTaskUnitRepresentabilityTests 17/17
ServiceEnvironmentTests, ServiceFilesTests, ServiceTextTests, SystemdUnitTests, WindowsTaskUnitTests, LaunchdUnitTests all pass

27 mutants, all detected. Each was applied to source, built, run, restored from git, and the restore verified by content — a git diff can hide a bad restore through alignment, which happened once during this work and silently flipped a different vendor's descriptor while reporting "0 deletions".

A sample of the ones that matter:

mutant caught by
deny-all MCP name back to a fixed literal the allowlist coupling + placeholder tests
SystemdValue / QuoteArg stop escaping % or $ 5/14 and 3/14
Unpercent becomes identity (round-trip broken) 2/14
Windows exec line quotes only on space 10/44
setlocal DisableDelayedExpansion moved after the env block exactly 1 — the positional test, proving it is not a duplicate of the presence test
XML guard reverts to char.IsControl 9/21
surrogate pair not consumed as a unit 3/21
control-character bound moved by one (DEL allowed) exactly 1
every sink's guard-then-escape helper stops guarding 2–7 each
--max-agents unvalidated 2/44
GOOGLE_API_KEY added to the capture allowlist exactly the two never-captured cases

Positive controls throughout, so no suite passes on a writer that simply rejects everything: an ordinary systemd unit still renders, a legal plist with an emoji log path still renders, a wrapper path with a space and an & is still accepted, and an ACP-injected MCP server still loads.

Deliberately not here

  • AI-1413 — the unattended reviewer: containment, approval posture, MCP clamp, and the sandbox credential question.
  • AI-1612 — currentModelId is auto-gemini-2.5, a placeholder-shaped id. With model selection withheld (NoOpModelSelector, because ConfigOptionModelSelector's write half is unverified and fails silently), a hosted Gemini session reports an unpriceable model. Known and accepted, not discovered later.
  • The stdio MCP flip — needs a purpose-built stdio server driven to a real tools/call, not an inference from the advertised mcpCapabilities (Kiro honours stdio without advertising it).
  • kcap status project warning — specced (§4.3), not implemented here; it needs to read the installed unit rather than the process environment, which is its own plumbing. Follow-up.

🤖 Generated with Claude Code

realtonyyoung and others added 6 commits July 31, 2026 01:51
Rev 1 + the nine round-1 spec-review findings. Three claims withdrawn rather
than defended: that GOOGLE_CLOUD_PROJECT is required on every auth path (it was
measured on one), that --skip-trust adds no risk surface (the probe never
established what the trust gate was gating), and that the eight Google
variables are safe to serialise because of what their values usually are (a
semantic label is not validation, and the Windows .cmd wrapper turns an
embedded quote into command execution).

Two answers came from reading code the reviewer could not see: service units
are written 0600 owner-only with a directory permission check, which bounds the
credential-path disclosure question; and SupportsMcpServers is a blanket switch
on the whole mcpServers array, not a per-transport one.

Round 2 returned seven further findings — NOT yet addressed. See the Linear
issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven findings; three were shape-changing, and two of those got smaller once I
read the code instead of arguing.

Validation belongs at the serialisation SINK, not at ServiceEnvironment.Build.
Not a theoretical bypass: DaemonCommands builds the env from Capture(profile)
and then adds KCAP_DAEMON_SUPERVISED to the dictionary, so a value already
reaches the writers without passing Build today. The Windows writer now
validates its own input for every key, which closes the pre-existing
PATH/KCAP_URL exposure rather than merely not widening it, and makes the
drop-recording contract unnecessary: service install fails with the key named.
The earlier writer table was also wrong about systemd — EnvAssignment quotes
and escapes properly; what it actually does is silently rewrite CR/LF.

The 0600 measurement answers Unix ONLY. Every permission path in ServiceFiles
begins with an IsWindows early return, commented "ACL-governed, inherited from
the user profile". So the secret-capable values — the credential path and the
two base URLs, which can carry userinfo or a query token — are captured
off-Windows and EXCLUDED on Windows, following the KCAP_COPILOT_TOKEN_CMD
precedent. Stated as a principle so future additions are not classified by
variable name: persist only when there is no non-persistent alternative AND the
platform gives an owner-only guarantee this code enforces.

The status predicate contradicted the spec's own capture table. "Project
configuration present" is now the OR of the two project variables — and that is
the vendor's definition, quoted from Gemini's own error text, not an inference
from reference counts. The alternate-only case gets a test.

The trust probe has a decision rule instead of "clamp or document": any
repository-controlled facility that can cause process execution before an
explicit user-approved action MUST be clamped; non-executable settings may be
documented. If it can execute and cannot be clamped, hosted Gemini does not
ship.

The pricing outcome is UNKNOWN, not "no cost by one of two mechanisms" — the
disjunction was not exhaustive, since a miss on the sentinel could still match a
pricing entry directly. The characterisation test is now gating, with a
three-way outcome table, and must not be written to expect one of them.

The approved diagnostic wording is in the spec, so the golden test pins that
rather than whatever the implementer writes first; plus the additive-wire
constraint, since a structured kind crosses a shape an older server consumes.

Two AI-1413 prescriptions relabelled as questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rev 4. The gating probe from rev 3 is measured, with a positive control, and the
answer is worse than the question:

  --skip-trust does NOT grant workspace-configuration trust. So the flag was
  never the risk, and the earlier draft's worry was aimed at the wrong thing.

  But trust INHERITS from a trusted parent, and --skip-trust cannot undo it.
  With a parent marked TRUST_PARENT and no entry for the worktree at all, a
  repo-authored MCP server AND a repo-authored SessionStart hook both executed
  while the flag was passed. Passing --skip-trust is not containment and must
  never be described as one.

  This is live for kcap. WorktreePathResolver and WorktreeManager put daemon
  worktrees at <repo>/.capacitor/worktrees/agent-..., inside the operator's
  repository. An operator who trusted their own repo — the normal thing to do,
  and the measuring machine has a real TRUST_PARENT entry — gets every hosted
  Gemini agent inheriting that trust and auto-executing whatever
  .gemini/settings.json the checked-out branch carries, under the daemon user,
  with no prompt, before the model acts. That branch may be PR-authored.

By §3.1's own rule that is a MUST-clamp. MCP servers are clampable and the flag
is now in the descriptor argv; hooks are NOT clampable by any launch flag —
`gemini hooks` is a management subcommand, there is no --no-hooks, and the hook
fired under every trusted configuration.

So hosted Gemini cannot ship on --skip-trust alone. Four options are tabled with
a recommendation (refuse to launch when the worktree resolves as trusted, plus
the MCP clamp, with worktree relocation as the follow-up that restores
availability). That is a decision, not an implementer's choice.

Also recorded because it is a verification trap: --allowed-mcp-server-names ""
crashes config load before session start, so a probe using it reports no markers
for entirely the wrong reason.
…ntained

The gating probe was run with gemini --prompt, the print/CLI path. kcap hosts
over ACP. Re-measured there with inherited trust:

  print/CLI : repo MCP EXECUTED, repo hook EXECUTED
  ACP       : repo MCP EXECUTED, repo hook BLOCKED
  ACP + --allowed-mcp-server-names <injected names> : both blocked

So 3.1a was wrong in both directions. Hooks do not execute on the ACP path, so
'hooks are unclampable, therefore hosted Gemini cannot ship' is withdrawn.
Repo-authored MCP servers do execute on ACP under inherited trust, and the
allowlist flag already in the descriptor contains it — injected server loads,
repo-authored one blocked.

The containment decision is no longer needed: no worktree relocation, no launch
refusal, no .gemini/ stripping. The real correction was the allowlist CONTENTS —
rev 4 wrote a non-matching sentinel, which would have blocked our own injected
servers and shipped hosted Gemini with MCP silently broken.

Spec is implementation-ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Interactive hosting only; the unattended reviewer stays its own issue, as with
Kiro. Argv is `--experimental-acp --skip-trust --allowed-mcp-server-names
kcap-none`.

`--skip-trust` is required and is NOT containment. Gemini refuses a headless
turn in an untrusted directory (exit 55, before any model call) so without it
every launch fails — but trust INHERITS from a trusted parent, and a daemon
worktree lives inside the operator's repository, so an operator who trusted
their own repo gives every hosted agent inherited trust over the checked-out
branch's configuration and the flag does not withdraw it. Measured.

Which is why the MCP allowlist is here. Under inherited trust a repo-authored
.gemini/settings.json MCP server WAS observed starting on the ACP path —
repo-controlled process execution under the daemon user, before the model acts,
on a branch that may be contributor-authored. Reducing the allowlist to a single
non-matching name blocks it. Repo-authored HOOKS were separately measured not to
run on the ACP path, though they do on the --prompt path; the two paths differ
and neither predicts the other, which is why the spec's earlier conclusion (that
hooks were the unclampable blocker) was wrong.

The allowlist contents are COUPLED to SupportsMcpServers. A non-matching name
permits nothing, which is correct only while nothing is injected; the day the
stdio call-level probe flips that flag, the list must become the injected server
names in the same change or hosted Gemini ships with MCP silently broken. A test
asserts the coupling in both directions, and it is mutation-proven: flipping the
flag alone fails exactly that test plus the constants test.

NoOpModelSelector for the same reason as Kiro — session/new returns models so the
read half fits, but the write half is unverified and that selector fails
SILENTLY. --approval-mode is deliberately not passed: hosting should behave as
the user's own session does.

The launch-failure hint is worded as a possibility, not a verdict, and that is a
requirement. Gemini reports a missing project with a message naming a tier
problem — thrown by throwIneligibleOrProjectIdError, the same text for both
causes — and reproducing that confidently-wrong attribution would be worse than
saying nothing. Asserted clause by clause rather than by keyword scan.

Still to come on this issue: the ServiceEnvironment capture of the Google/Vertex
configuration with its platform split, the kcap status warning, and the README
section.
…unit

A supervised daemon inherits nothing from an interactive shell — launchd passes
no shell environment and a non-interactive shell never reads a profile — so a
project exported in .zshrc is invisible to a hosted Gemini agent. Gemini then
reports the absence as a TIER problem (IneligibleTierError, thrown by
throwIneligibleOrProjectIdError, which emits the same text for a missing project
id), which sends people to entirely the wrong place. ServiceEnvironment now
captures the configuration so that cannot happen.

Both project spellings are carried because Gemini honours both; its own error
says "The GOOGLE_CLOUD_PROJECT (or GOOGLE_CLOUD_PROJECT_ID) environment variable
must be set".

Platform split, stated as a principle rather than per variable: persist a value
into a unit only when there is no non-persistent alternative AND the platform
gives an owner-only guarantee this code actually enforces.

  everywhere : the two project spellings, location, and the two USE_* booleans
  Unix only  : GOOGLE_APPLICATION_CREDENTIALS and the two base URLs — a
               credential PATH discloses where the credential is, and a URL can
               carry userinfo or a query token. Bounded on Unix by ServiceFiles
               writing 0600, re-checking the mode on the open handle (umask
               filters UnixCreateMode, so the request is not the result) and
               refusing a group/other-writable directory. Every one of those
               paths returns early on Windows — "ACL-governed, inherited from
               the user profile" — so there is no equivalent guarantee and those
               three are excluded there, exactly as KCAP_COPILOT_TOKEN_CMD is.
  never      : GOOGLE_API_KEY, GOOGLE_CREDENTIALS. Named in code rather than
               merely absent, so adding a GOOGLE_* key forces a decision.

Also closes a pre-existing hole rather than only declining to widen it. The
Windows wrapper emits `set "K=V"` with only % escaped, so an embedded quote
CLOSES the assignment and &, |, <, >, ^ become live batch metacharacters in a
file the service executes. WindowsTaskUnit now validates its own input for every
key — PATH and KCAP_URL included — and fails the install naming the key.

The check is at the sink deliberately, not at ServiceEnvironment.Build: Build is
not a boundary, because DaemonCommands adds KCAP_DAEMON_SUPERVISED to the
dictionary AFTER Capture returns, so a value already reaches the writer without
passing through it. The tests call the writer directly for the same reason —
routing through Build would pass even if the writer were defenceless — and
include a value arriving by that exact bypass route.

Both guards mutation-proven: removing the sink check fails 8 of 13 (the 5
survivors are the positive controls, which is what stops the suite passing on a
writer that rejects everything); adding GOOGLE_API_KEY to the allowlist fails
exactly the two never-captured cases.

README documents the daemon-not-your-shell requirement, the misattributed error,
the platform table, the install-time capture footgun, and which auth method was
actually verified.
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown

AI-899

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Host Gemini CLI as an ACP hosted agent (interactive-only)

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

Grey Divider

AI Description

• Add Gemini CLI as a new ACP hosted-agent vendor (interactive only).
• Capture required Google/Gemini env into daemon service units with Windows exclusions.
• Block Windows service-wrapper env injection; add coupling/guard tests and docs.
Diagram

graph TD
  A["DaemonRunner"] --> B["AcpHostedAgentRuntimeFactory"] --> C["AcpHostedAgentRuntime"] --> D{{"Gemini CLI"}}
  E["ServiceEnvironment"] --> F["Service unit env"] --> D
  F --> G["WindowsTaskUnit"]

  subgraph Legend
    direction LR
    _mod["Module"] ~~~ _rt(["Runtime/Factory"]) ~~~ _ext{{"External CLI"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Relocate daemon worktrees outside the repo
  • ➕ Avoids inherited trust from an operator-trusted repository path
  • ➕ Reduces reliance on vendor-specific clamps for repo-controlled facilities
  • ➖ Cross-cutting change to worktree discovery/management across all vendors
  • ➖ More operational migration risk (paths, cleanup, multiple repos)
2. Fail closed when worktree is effectively trusted
  • ➕ Strong containment without modifying the checkout
  • ➕ Keeps mitigation local to Gemini hosting path
  • ➖ Disables hosted Gemini in repos users are most likely to trust
  • ➖ Requires reading/interpreting Gemini trust state reliably
3. Strip/neutralize .gemini/ before launch
  • ➕ Directly removes repo-authored MCP server definitions from the worktree
  • ➕ Independent of upstream trust semantics
  • ➖ Mutates a checkout agents commit from; tricky with tracked files
  • ➖ Potentially surprising behavior and harder to reason about debugging

Recommendation: Keep the PR’s current approach: launch Gemini over ACP with --skip-trust (required for headless turns) and clamp repo-authored MCP servers via a non-matching --allowed-mcp-server-names sentinel while SupportsMcpServers remains false. The added coupling test makes the future MCP flip safer, and the WindowsTaskUnit sink validation is the right boundary to prevent command injection regardless of which env key carries hostile content. Revisit stronger trust-inheritance mitigations (worktree relocation or fail-closed) only if/when Gemini’s trust model expands beyond the MCP surface or if MCP enablement becomes necessary.

Files changed (10) +1144 / -4

Enhancement (4) +151 / -2
AcpVendorDescriptor.csDefine Gemini ACP vendor descriptor with MCP clamp sentinel +60/-0

Define Gemini ACP vendor descriptor with MCP clamp sentinel

• Adds 'AcpVendorDescriptors.Gemini' with '--experimental-acp --skip-trust' and a non-empty MCP allowlist sentinel to block repo-authored servers. Sets interactive-only hosting ('SupportsUnattended: false'), disables MCP injection ('SupportsMcpServers: false'), and uses 'NoOpModelSelector' to avoid unverified silent model-setting behavior.

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs

DaemonRunner.csRegister Gemini AcpHostedAgentRuntimeFactory in the daemon +8/-0

Register Gemini AcpHostedAgentRuntimeFactory in the daemon

• Adds a DI registration entry for the Gemini hosted-agent runtime factory so the daemon can instantiate Gemini ACP hosted agents.

src/Capacitor.Cli.Daemon/DaemonRunner.cs

AcpHostedAgentRuntime.csAdd Gemini launch-failure auth/project configuration hint +30/-1

Add Gemini launch-failure auth/project configuration hint

• Introduces a Gemini-specific diagnostic hint string designed to be explicitly non-diagnostic (“may be… or may be unrelated”). The hint directs operators to ensure 'gemini' is logged in and project variables are set in the daemon-visible service environment, not shell profiles.

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

ServiceEnvironment.csCapture Google/Gemini env into service units with Windows split +53/-1

Capture Google/Gemini env into service units with Windows split

• Extends the service environment allowlist to include non-secret Google project/backend variables on all platforms. Adds a Windows-only exclusion for secret-capable values (ADC path and base URL overrides) and explicitly forbids capturing 'GOOGLE_API_KEY' and 'GOOGLE_CREDENTIALS' on any platform.

src/Capacitor.Cli/Services/ServiceEnvironment.cs

Bug fix (1) +42 / -1
WindowsTaskUnit.csValidate env values in Windows .cmd wrapper to prevent command injection +42/-1

Validate env values in Windows .cmd wrapper to prevent command injection

• Adds representability checks at the Windows wrapper sink, rejecting values containing quotes or newlines for every environment key. Fails service installation with a key-naming error instead of emitting a potentially executable batch script.

src/Capacitor.Cli/Services/WindowsTaskUnit.cs

Tests (3) +251 / -0
AcpVendorDescriptorTests.csAdd Gemini descriptor tests (argv, MCP coupling, diagnostic hint) +104/-0

Add Gemini descriptor tests (argv, MCP coupling, diagnostic hint)

• Adds tests asserting Gemini’s shipped binary default, descriptor constants, and 'SupportsMcpServers'/allowlist coupling. Includes a “golden-ish” test to ensure the Gemini auth hint remains suggestive and references daemon-visible project configuration.

test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs

ServiceEnvironmentTests.csTest Google/Gemini env capture rules and platform exclusions +77/-0

Test Google/Gemini env capture rules and platform exclusions

• Adds unit tests verifying capture of Google config variables off-Windows, exclusion of 'GOOGLE_API_KEY'/'GOOGLE_CREDENTIALS' everywhere, and exclusion of secret-capable values on Windows. Also asserts absent/empty inputs do not produce empty environment entries.

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

WindowsTaskUnitRepresentabilityTests.csAdd sink-level tests for WindowsTaskUnit representability rejection +70/-0

Add sink-level tests for WindowsTaskUnit representability rejection

• Introduces direct tests for 'WindowsTaskUnit.Wrapper' to ensure hostile values (quotes/newlines) are rejected regardless of env key, and that error messages name the offending key. Includes positive controls to ensure legitimate values still render correctly.

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

Documentation (2) +700 / -1
README.mdDocument Gemini daemon env requirements and install-time capture footgun +25/-1

Document Gemini daemon env requirements and install-time capture footgun

• Extends the daemon service environment documentation to include Google/Gemini variables. Explains why 'GOOGLE_CLOUD_PROJECT' must be visible to the supervised daemon, clarifies the misleading tier error, and documents platform-specific capture exclusions (notably Windows).

README.md

2026-07-31-ai899-gemini-acp-hosted-agent-design.mdAdd AI-899 design spec for Gemini ACP hosted agent +675/-0

Add AI-899 design spec for Gemini ACP hosted agent

• Introduces a detailed implementation-ready spec covering Gemini descriptor choices, trust/MCP containment probes, environment capture policy, Windows serialization hazards, and required tests. Serves as the design record and rationale for the code changes in this PR.

docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Gemini MCP clamp bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
The Gemini descriptor uses a fixed allowlist name ("kcap-none") to block repo-authored MCP servers,
but a repository can name its MCP server "kcap-none" and satisfy the allowlist, re-enabling
repo-controlled process execution under inherited trust. This defeats the stated containment
mechanism for the exact threat model described in the spec/comments (repo-authored MCP auto-start).
Code

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[R268-322]

+    /// <summary>
+    /// A name no MCP server will ever have, passed to Gemini's <c>--allowed-mcp-server-names</c> so the
+    /// allowlist permits nothing. See <see cref="Gemini"/> for why "nothing" is the correct contents
+    /// today, and why this must change in lockstep with <c>SupportsMcpServers</c>.
+    ///
+    /// <para>It must be non-empty: <c>--allowed-mcp-server-names ""</c> fails Gemini's config load with
+    /// <i>"mcpName is required if specified"</i> BEFORE the session starts — which also makes it a trap
+    /// when verifying this by hand, because the launch then fails for a reason unrelated to MCP and
+    /// reports nothing loaded.</para>
+    /// </summary>
+    internal const string GeminiNoMcpSentinel = "kcap-none";
+
+    /// <summary>Google Gemini CLI as an ACP hosted agent (<c>gemini --experimental-acp</c>).
+    /// Interactive hosting only; the unattended reviewer is its own issue, as with Kiro.
+    ///
+    /// <para><b><c>--skip-trust</c> is required, and is NOT a containment measure.</b> Gemini refuses a
+    /// headless turn in an untrusted directory outright — <c>exit 55</c> before any model call — and a
+    /// daemon-created worktree cannot be assumed pre-trusted, so without the flag every launch fails.
+    /// What it does NOT do is protect anything: trust INHERITS from a trusted parent directory, and a
+    /// daemon worktree lives at <c>&lt;repo&gt;/.capacitor/worktrees/agent-…</c>, INSIDE the operator's
+    /// repository. An operator who has trusted their own repo therefore gives every hosted Gemini agent
+    /// inherited trust over the checked-out branch's configuration — and passing <c>--skip-trust</c> does
+    /// not withdraw it. Measured, not assumed.</para>
+    ///
+    /// <para><b>Which is why the MCP allowlist is here.</b> Under inherited trust, a repository-authored
+    /// <c>.gemini/settings.json</c> MCP server WAS observed starting on the ACP path — repo-controlled
+    /// process execution under the daemon user, before the model acts, on a branch that may be
+    /// contributor-authored. <see cref="GeminiNoMcpSentinel"/> reduces the allowlist to nothing, which
+    /// blocks it. Repo-authored <i>hooks</i> were separately measured NOT to run on the ACP path (they do
+    /// on the <c>--prompt</c> path — the two paths differ, and neither predicts the other).</para>
+    ///
+    /// <para><b>The sentinel is only correct while <see cref="AcpVendorDescriptor.SupportsMcpServers"/> is
+    /// false.</b> It permits nothing, so with nothing injected it costs nothing. The day the stdio
+    /// call-level probe flips that flag, this list must become the injected server names in the SAME
+    /// change, or hosted Gemini ships with MCP silently broken. <c>AcpVendorDescriptorTests</c> asserts
+    /// the coupling so the two cannot drift apart.</para>
+    ///
+    /// <para><see cref="NoOpModelSelector"/> for the same reason as Kiro: <c>session/new</c> does return a
+    /// <c>models</c> object, so <see cref="ConfigOptionModelSelector"/>'s read half would fit, but its
+    /// write half is unverified on Gemini and that selector fails SILENTLY — a session that reports the
+    /// requested model while running another. <c>ResolveDefaultModel: null</c> alone is not enough,
+    /// because <c>ResolveRequestedModel</c> prioritises a per-launch model and would reach a live
+    /// selector anyway.</para>
+    ///
+    /// <para><c>--approval-mode</c> is deliberately not passed: interactive hosting should behave as the
+    /// user's own session does, and pinning <c>plan</c> would silently make hosted Gemini read-only.
+    /// <c>DiagnosticBinary</c> needs no branch — the vendor key and the binary name are both
+    /// <c>gemini</c>.</para></summary>
+    public static readonly AcpVendorDescriptor Gemini = new(
+        Vendor:              "gemini",
+        ResolveBinaryPath:   cfg => cfg.GeminiPath,
+        ResolveDefaultModel: _ => null,
+        Argv:                ["--experimental-acp", "--skip-trust",
+                              "--allowed-mcp-server-names", GeminiNoMcpSentinel],
+        UnattendedTrustArgv: [],
Evidence
The descriptor hardcodes GeminiNoMcpSentinel = "kcap-none" and passes it to
--allowed-mcp-server-names, intending it to be a permanently non-matching name. The accompanying
design spec states the allowlist works by name matching (“non-matching name permits nothing”), which
implies a repo-authored server using the same name would be permitted and bypass the clamp.

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[268-326]
docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[234-246]

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

## Issue description
Gemini is launched with `--allowed-mcp-server-names kcap-none` as a “non-matching sentinel” to block repo-authored MCP servers. Because the sentinel is a fixed public string, a repo can simply name its MCP server `kcap-none`, which would match the allowlist and be permitted to start, re-opening the repo-controlled process execution path.

## Issue Context
The code comments/spec explicitly rely on “non-matching name permits nothing”, but a constant string cannot be guaranteed non-matching against repository-controlled configuration.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[268-326]
- src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs[323-350]
- test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[226-282]

## Implementation direction
- Stop using a fixed sentinel value for Gemini when `SupportsMcpServers == false`.
- Generate a per-launch (or per-session) unpredictable allowlist name and pass it via argv rewrite in `BuildProcessStartInfo` (or similar spawn seam). Because no MCP servers are injected today, this random allowlist will not break functionality but will prevent a repo from pre-selecting the allowed name.
- Update tests:
 - Descriptor tests should assert presence of `--allowed-mcp-server-names` (and the coupling behavior), but should not assert an exact constant sentinel if the value becomes per-launch.
 - Add a test that proves the allowlist value is not a fixed constant when `SupportsMcpServers` is false (e.g., two builds produce different values, or it matches a GUID-like pattern).
- Keep the existing non-empty constraint (avoid the empty-string crash noted in comments/spec).

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



Remediation recommended

2. Verbose ServiceText doc comments 📘 Rule violation ⚙ Maintainability ⭐ New
Description
The PR introduces multi-paragraph explanatory comments in ServiceText that significantly exceed
concise documentation and duplicate information that could be captured via clearer naming/factoring.
This increases maintenance burden and reduces readability, violating the “keep comments minimal”
requirement.
Code

src/Capacitor.Cli/Services/ServiceText.cs[R22-139]

+    /// <summary>
+    /// Escape a systemd <c>Environment=</c>/<c>Description=</c> value: literal percent signs doubled.
+    ///
+    /// <para><b>Why the doubling.</b> systemd expands <i>specifiers</i> — <c>%n</c>, <c>%h</c>, <c>%i</c> and
+    /// friends — in both of those directives, so a value carrying a literal <c>%</c> does not survive: a
+    /// <c>PATH</c> containing <c>%n</c> silently becomes the unit name, and a percent sequence systemd does
+    /// not recognise makes it refuse to load the unit at all. <c>%%</c> is systemd's own escape for one
+    /// literal percent, so this is a faithful round-trip rather than a mangling.</para>
+    ///
+    /// <para>This is the same primitive <see cref="CmdValue"/> applies for cmd.exe, for the same reason, and
+    /// it was missing here while present there — the asymmetry is what review caught.</para>
+    ///
+    /// <para>It no longer normalises CR/LF to spaces: <see cref="RequireNoControlCharacters"/> refuses them
+    /// at the sink first, so the replacement was unreachable — and silently rewriting a caller's value was
+    /// the wrong behaviour anyway.</para>
+    /// </summary>
+    public static string SystemdValue(string value) => value.Replace("%", "%%");
+
+    /// <summary>
+    /// Rejects a VALUE that XML 1.0 cannot represent, for the plist writer.
+    ///
+    /// <para>Entity escaping does not help here, and that is the point: <c>SecurityElement.Escape</c> turns
+    /// <c>&amp;</c> and <c>&lt;</c> into entities, but U+0001 has no legal XML 1.0 representation at all —
+    /// escaped or not. XML 1.0 permits only #x9, #xA, #xD and #x20 upwards. POSIX environment values, by
+    /// contrast, may contain any byte except NUL, so a captured or directly composed value really can carry
+    /// one.</para>
+    ///
+    /// <para>The consequence is availability rather than injection: the plist becomes unparseable, so
+    /// <c>launchctl</c> refuses to load it and the service cannot install or restart. Failing at write time
+    /// names the variable; failing at load time produces a service that silently does not exist.</para>
+    /// </summary>
+    public static void RequireXmlRepresentableValue(string name, string value) {
+        // The predicate is XmlConvert's, not char.IsControl — the two are NOT equivalent, and review caught
+        // both directions of the gap. char.IsControl accepts U+FFFE, U+FFFF and lone surrogates, none of
+        // which XML can carry (a lone surrogate is not even a scalar value, so the encoder either throws or
+        // substitutes), and it rejects U+007F–U+009F, which XML 1.0 permits. Deferring to the platform
+        // predicate makes the code match the doc comment above instead of approximating it.
+        //
+        // Scanned by index rather than foreach so a valid surrogate PAIR can be recognised as the one
+        // supplementary character it encodes; checking `char` in isolation would reject every emoji.
+        for (var i = 0; i < value.Length; i++) {
+            var c = value[i];
+
+            if (char.IsHighSurrogate(c) && i + 1 < value.Length
+             && XmlConvert.IsXmlSurrogatePair(value[i + 1], c)) {
+                i++; // consumed the low half too
+                continue;
+            }
+
+            if (XmlConvert.IsXmlChar(c)) continue;
+
+            throw new InvalidOperationException(
+                $"Refusing to write a service unit: the value of '{name}' contains U+{(int)c:X4}, which "
+              + "XML 1.0 cannot represent even escaped — the resulting plist would not load. Unset or "
+              + "correct that variable, then re-run `kcap daemon service install`.");
+        }
+    }
+
+    /// <summary>
+    /// Rejects an environment-variable NAME that no unit writer can carry safely. Shared by all three
+    /// sinks, because every one of them interpolates the name into a line whose structure the name can
+    /// break — and each format then hands the attacker something different.
+    ///
+    /// <para><b>systemd is the worst of the three, which is why this is central rather than per-writer.</b>
+    /// A key shaped like <c>SAFE=ok\nExecStartPre=/bin/touch /tmp/pwned\n#</c> with an ordinary value
+    /// renders a valid <c>Environment=SAFE=ok</c> line, then an attacker-chosen <c>ExecStartPre=</c>
+    /// directive, then comments out the remainder — a command the service runs on every restart. Value
+    /// escaping does not help: <c>SystemdValue</c> normalises the VALUE, and <c>EnvAssignment</c> decides
+    /// quoting from the VALUE. Neither looks at the name.</para>
+    ///
+    /// <para>The rule is the POSIX environment-name grammar (<c>[A-Za-z_][A-Za-z0-9_]*</c>) rather than a
+    /// blacklist of the characters each format happens to fear. A blacklist has to be right three times,
+    /// once per format, and a fourth writer would need it extended; an allowlist of what a legitimate name
+    /// can contain is right by construction. Nothing on any capture allowlist is excluded by it, so a
+    /// rejection means a caller composed the dictionary directly — which is exactly the path that bypasses
+    /// <see cref="ServiceEnvironment"/>.</para>
+    /// </summary>
+    public static void RequireValidEnvName(string name) {
+        var ok = name.Length > 0
+              && (char.IsAsciiLetter(name[0]) || name[0] == '_')
+              && name.All(c => char.IsAsciiLetterOrDigit(c) || c == '_');
+
+        if (ok) return;
+
+        throw new InvalidOperationException(
+            $"Refusing to write a service unit: '{name}' is not a valid environment variable name "
+          + "([A-Za-z_][A-Za-z0-9_]*). A name outside that grammar can break the unit's own line "
+          + "structure — in a systemd unit it can inject an ExecStartPre= directive that runs on every "
+          + "restart. No name from the capture allowlist is affected, so this one was added directly.");
+    }
+
+    /// <summary>
+    /// Rejects a value carrying a C0 control character or DEL, for the systemd writer.
+    ///
+    /// <para>Quoting does not make a raw control byte valid unit syntax, and this writer has no encoder for
+    /// one: <see cref="SystemdValue"/> handles expansion, <c>Esc</c> handles the backslash and the double
+    /// quote, and neither touches U+0001, a backspace or a vertical tab — all of which a POSIX environment
+    /// value, a filename or an argv string may legally contain. systemd does document C-style and hex
+    /// escapes, but this code has no way to exercise a real systemd parser, so it refuses the input rather
+    /// than shipping an escape whose behaviour it has only read about. That is the same call already made for
+    /// the bare <c>;</c> separator, and the same reason.</para>
+    ///
+    /// <para>CR and LF are included, which supersedes the old newline-to-space normalisation in
+    /// <see cref="SystemdValue"/>: silently rewriting a value the caller supplied is worse than declining to
+    /// write it, because a service that runs with a value nobody chose is harder to diagnose than an install
+    /// that failed and said which variable was at fault.</para>
+    /// </summary>
+    public static void RequireNoControlCharacters(string name, string value) {
+        foreach (var c in value) {
+            if (!char.IsControl(c) || c > '\u007F') continue;
+
+            throw new InvalidOperationException(
+                $"Refusing to write a systemd unit: the value of '{name}' contains the control character "
+              + $"U+{(int)c:X4}. A unit directive has no encoding for one that this code can verify, and "
+              + "quoting does not make it valid syntax — systemd would refuse to load the unit. Unset or "
+              + "correct that variable, then re-run `kcap daemon service install`.");
+        }
+    }
Evidence
PR Compliance ID 6 requires comments to be concise and to prefer self-explanatory code. The added
XML doc blocks and inline commentary in ServiceText are multi-paragraph and rationale-heavy across
multiple helpers, indicating excessively verbose commenting rather than minimal necessary
documentation.

CLAUDE.md: Keep code comments minimal; prefer self-explanatory code
src/Capacitor.Cli/Services/ServiceText.cs[22-139]

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

## Issue description
`ServiceText` contains very long, multi-paragraph comments that reduce readability and increase future maintenance cost.

## Issue Context
PR Compliance requires keeping comments minimal and preferring self-explanatory code; these comments are substantially longer than necessary for API docs.

## Fix Focus Areas
- src/Capacitor.Cli/Services/ServiceText.cs[22-139]

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


3. Linear IDs in spec doc 📘 Rule violation ⚙ Maintainability
Description
The new spec document includes Linear issue identifiers (e.g., AI-899) in repository text,
coupling documentation to Linear-specific numbering. This violates the requirement to avoid Linear
issue keys in comments/docs and use GitHub issue references instead when needed.
Code

docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[R1-8]

+# AI-899 — Gemini CLI as an ACP hosted agent
+
+**Status:** rev 5, 2026-07-31, against `gemini 0.53.0` and `origin/main` (`e2b2821`).
+**Implementation-ready.** §3.1a's gating trust probe has been run, re-run on the correct code path, and its
+finding is contained by a flag already in the descriptor.
+**Repository:** kurrent-io/kcap-cli
+**Parent:** AI-1399 (multi-vendor ACP hosted agents)
+**Template:** the shipped Kiro child (AI-1404) and the Copilot child (AI-1403). Read those, not the
Evidence
PR Compliance ID 5 forbids Linear issue identifiers like AI-###. The newly added spec begins with
# AI-899 — ..., introducing a Linear key into repository documentation.

CLAUDE.md: Do Not Reference Linear Issue Numbers in Code Comments
docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[1-8]

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

## Issue description
Repository documentation includes Linear issue keys (e.g., `AI-899`), which should not be referenced in repo comments/docs.

## Issue Context
Compliance requires avoiding Linear-specific identifiers; if an issue reference is necessary, use a GitHub issue/PR link or number.

## Fix Focus Areas
- docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[1-8]

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


4. Allowlist test misindexes ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
Gemini_McpAllowlist_IsCoupledToSupportsMcpServers reads argv[Array.IndexOf(...)+1] without
asserting the flag exists and has a following value, so the test can inspect the wrong element or
throw an index error instead of clearly failing on missing/invalid structure. This makes future
regressions around the allowlist flag harder to diagnose and the assertion less robust.
Code

test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[R269-274]

+    public async Task Gemini_McpAllowlist_IsCoupledToSupportsMcpServers() {
+        var descriptor = AcpVendorDescriptors.Gemini;
+        var argv       = descriptor.Argv.ToArray();
+        var allowed    = argv[Array.IndexOf(argv, "--allowed-mcp-server-names") + 1];
+
+        await Assert.That(allowed).IsNotEmpty();
Evidence
The new test uses Array.IndexOf(...)+1 indexing directly. If the flag is absent or is the last
element, this expression reads the wrong argv slot or indexes out of bounds, which is not a clear
structural assertion for a coupling guard.

test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[256-282]

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

## Issue description
The test extracts the allowlist value via `argv[Array.IndexOf(argv, "--allowed-mcp-server-names") + 1]` but doesn’t assert that the flag is present or that a value exists at `index+1`. This is an unsafe pattern that can produce misleading failures (or read argv[0]) if argv shape changes.

## Issue Context
This test is meant to be a guard against drift; it should fail loudly and precisely when the flag/value is missing.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[256-282]

## Suggested fix
- Compute `var ix = Array.IndexOf(argv, "--allowed-mcp-server-names");`
- Assert `ix >= 0`.
- Assert `ix + 1 < argv.Length`.
- Then read `var allowed = argv[ix + 1];`.
- (Optional) Also assert the flag appears exactly once to prevent ambiguous parsing.

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


View more (1)
5. Verbose Gemini descriptor comments 📘 Rule violation ⚙ Maintainability
Description
The PR adds very long explanatory XML doc comments (multi-paragraph rationale and operational
guidance) directly in production code, reducing readability and increasing maintenance burden. This
violates the guideline to keep comments minimal and prefer self-explanatory code with concise,
necessary comments.
Code

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[R280-315]

+    /// <summary>Google Gemini CLI as an ACP hosted agent (<c>gemini --experimental-acp</c>).
+    /// Interactive hosting only; the unattended reviewer is its own issue, as with Kiro.
+    ///
+    /// <para><b><c>--skip-trust</c> is required, and is NOT a containment measure.</b> Gemini refuses a
+    /// headless turn in an untrusted directory outright — <c>exit 55</c> before any model call — and a
+    /// daemon-created worktree cannot be assumed pre-trusted, so without the flag every launch fails.
+    /// What it does NOT do is protect anything: trust INHERITS from a trusted parent directory, and a
+    /// daemon worktree lives at <c>&lt;repo&gt;/.capacitor/worktrees/agent-…</c>, INSIDE the operator's
+    /// repository. An operator who has trusted their own repo therefore gives every hosted Gemini agent
+    /// inherited trust over the checked-out branch's configuration — and passing <c>--skip-trust</c> does
+    /// not withdraw it. Measured, not assumed.</para>
+    ///
+    /// <para><b>Which is why the MCP allowlist is here.</b> Under inherited trust, a repository-authored
+    /// <c>.gemini/settings.json</c> MCP server WAS observed starting on the ACP path — repo-controlled
+    /// process execution under the daemon user, before the model acts, on a branch that may be
+    /// contributor-authored. <see cref="GeminiNoMcpSentinel"/> reduces the allowlist to nothing, which
+    /// blocks it. Repo-authored <i>hooks</i> were separately measured NOT to run on the ACP path (they do
+    /// on the <c>--prompt</c> path — the two paths differ, and neither predicts the other).</para>
+    ///
+    /// <para><b>The sentinel is only correct while <see cref="AcpVendorDescriptor.SupportsMcpServers"/> is
+    /// false.</b> It permits nothing, so with nothing injected it costs nothing. The day the stdio
+    /// call-level probe flips that flag, this list must become the injected server names in the SAME
+    /// change, or hosted Gemini ships with MCP silently broken. <c>AcpVendorDescriptorTests</c> asserts
+    /// the coupling so the two cannot drift apart.</para>
+    ///
+    /// <para><see cref="NoOpModelSelector"/> for the same reason as Kiro: <c>session/new</c> does return a
+    /// <c>models</c> object, so <see cref="ConfigOptionModelSelector"/>'s read half would fit, but its
+    /// write half is unverified on Gemini and that selector fails SILENTLY — a session that reports the
+    /// requested model while running another. <c>ResolveDefaultModel: null</c> alone is not enough,
+    /// because <c>ResolveRequestedModel</c> prioritises a per-launch model and would reach a live
+    /// selector anyway.</para>
+    ///
+    /// <para><c>--approval-mode</c> is deliberately not passed: interactive hosting should behave as the
+    /// user's own session does, and pinning <c>plan</c> would silently make hosted Gemini read-only.
+    /// <c>DiagnosticBinary</c> needs no branch — the vendor key and the binary name are both
+    /// <c>gemini</c>.</para></summary>
Evidence
PR Compliance ID 4 requires minimal comments. The added XML documentation for
AcpVendorDescriptors.Gemini is a multi-paragraph narrative (trust behavior, MCP containment,
coupling rules, model selection rationale), which is far beyond brief/necessary commentary.

CLAUDE.md: Keep Comments Minimal; Prefer Self-Explanatory Code
src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[280-315]

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

## Issue description
New production-code comments are overly verbose, embedding extensive design/spec narrative where concise documentation should suffice.

## Issue Context
Long rationale blocks make code harder to scan and keep correct over time; the deeper reasoning can live in the spec/README, while code should retain only the minimal invariants required for correct maintenance.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[280-315]

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



Informational

6. Duplicated doc summaries ✓ Resolved 🐞 Bug ⚙ Maintainability ⭐ New
Description
The new documentation blocks create back-to-back <summary> elements that become a single doc comment
attached to the next member, producing confusing/mis-associated API docs (and potentially
documentation-tool warnings if XML docs are generated). This happens in WindowsTaskUnit (the added
comments before Guarded) and similarly in AcpHostedAgentRuntimeFactory (the new
SubstituteUnmatchableNames docs immediately follow an existing summary block).
Code

src/Capacitor.Cli/Services/WindowsTaskUnit.cs[R122-154]

+    /// <summary>
+    /// Rejects a wrapper path <c>cmd /c</c> cannot be handed safely.
+    ///
+    /// <para>The Task action's argument text is parsed by cmd, not by CreateProcess, so it is a command line
+    /// and not an opaque string. Two characters cannot be made safe there:</para>
+    ///
+    /// <para><c>%</c> — cmd expands <c>%NAME%</c> in the <c>/c</c> command text before opening the file, and
+    /// the <c>%%</c> escape is a BATCH-FILE construct that does not apply to a command line. There is
+    /// therefore no encoding for a literal percent at this sink, only refusal. (The wrapper's own body is a
+    /// batch file, which is why <see cref="ServiceText.CmdValue"/> is the right answer there and not here —
+    /// the same character, two sinks, two different correct treatments.)</para>
+    ///
+    /// <para><c>"</c>, CR, LF — structural: they end the quoted command or the line.</para>
+    ///
+    /// <para>Reachability, since <c>PathHelpers</c> composing the path does NOT make it safe — review made
+    /// exactly that point: the path runs through the config directory, so it carries the account name, and
+    /// Windows permits both <c>%</c> and <c>&amp;</c> in an account name; <c>KCAP_CONFIG_DIR</c> can set it
+    /// outright. <c>&amp;</c> itself is handled by the nested-quote form below rather than refused, so an
+    /// ordinary path keeps working.</para>
+    /// </summary>
+    /// <summary>
+    /// Guard, then escape — every string interpolated into the Task XML goes through here, mirroring the
+    /// plist writer's helper.
+    ///
+    /// <para>Composition does not imply XML validity: <c>wrapperPath</c> carries <c>KCAP_CONFIG_DIR</c>
+    /// verbatim, Windows paths are native UTF-16, and U+FFFE, U+FFFF or a malformed surrogate unit is outside
+    /// XML 1.0 while <c>SecurityElement.Escape</c> passes all of them through untouched. The consequence is
+    /// the same availability failure the plist had — the task cannot be registered — so it gets the same
+    /// treatment. Applied to the service id too, which is sanitized and therefore safe already: a structural
+    /// invariant that holds at every interpolation is worth more than one applied where it is needed, because
+    /// the next value added here inherits it.</para>
+    /// </summary>
+    static string Guarded(string what, string value) {
Evidence
In WindowsTaskUnit, two consecutive /// <summary> blocks appear immediately before `static string
Guarded(...), meaning they are treated as one doc comment for Guarded`. In
AcpHostedAgentRuntimeFactory, the new summary for SubstituteUnmatchableNames is inserted directly
after an existing </summary> with no intervening declaration, likewise concatenating summaries
onto the same following member.

src/Capacitor.Cli/Services/WindowsTaskUnit.cs[122-157]
src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs[260-291]

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

### Issue description
New `///` XML-doc blocks were inserted without a declaration boundary between them, so multiple `<summary>` elements are concatenated into the same XML doc comment and attached to the following method. This mis-documents the methods and can confuse IDE tooltips and any doc generation.

### Issue Context
This was introduced when adding new helper documentation in `WindowsTaskUnit` and `AcpHostedAgentRuntimeFactory`.

### Fix Focus Areas
- src/Capacitor.Cli/Services/WindowsTaskUnit.cs[122-170]
- src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs[260-293]

### What to change
- Ensure each method has exactly one XML doc comment block.
- Move the first summary block in `WindowsTaskUnit` to sit immediately above `RequireSafeWrapperPath`, leaving only the Guarded-related summary above `Guarded`.
- In `AcpHostedAgentRuntimeFactory`, move the model-override summary block so it documents `ResolveRequestedModel` (or whichever method it was intended for), and leave the new `SubstituteUnmatchableNames` summary as the only `<summary>` block attached to that helper.

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


Grey Divider

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

Previous review results

Review updated until commit 667fc72

Results up to commit 50cb99b ⚖️ Balanced


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


Action required
1. Gemini MCP clamp bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
The Gemini descriptor uses a fixed allowlist name ("kcap-none") to block repo-authored MCP servers,
but a repository can name its MCP server "kcap-none" and satisfy the allowlist, re-enabling
repo-controlled process execution under inherited trust. This defeats the stated containment
mechanism for the exact threat model described in the spec/comments (repo-authored MCP auto-start).
Code

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[R268-322]

+    /// <summary>
+    /// A name no MCP server will ever have, passed to Gemini's <c>--allowed-mcp-server-names</c> so the
+    /// allowlist permits nothing. See <see cref="Gemini"/> for why "nothing" is the correct contents
+    /// today, and why this must change in lockstep with <c>SupportsMcpServers</c>.
+    ///
+    /// <para>It must be non-empty: <c>--allowed-mcp-server-names ""</c> fails Gemini's config load with
+    /// <i>"mcpName is required if specified"</i> BEFORE the session starts — which also makes it a trap
+    /// when verifying this by hand, because the launch then fails for a reason unrelated to MCP and
+    /// reports nothing loaded.</para>
+    /// </summary>
+    internal const string GeminiNoMcpSentinel = "kcap-none";
+
+    /// <summary>Google Gemini CLI as an ACP hosted agent (<c>gemini --experimental-acp</c>).
+    /// Interactive hosting only; the unattended reviewer is its own issue, as with Kiro.
+    ///
+    /// <para><b><c>--skip-trust</c> is required, and is NOT a containment measure.</b> Gemini refuses a
+    /// headless turn in an untrusted directory outright — <c>exit 55</c> before any model call — and a
+    /// daemon-created worktree cannot be assumed pre-trusted, so without the flag every launch fails.
+    /// What it does NOT do is protect anything: trust INHERITS from a trusted parent directory, and a
+    /// daemon worktree lives at <c>&lt;repo&gt;/.capacitor/worktrees/agent-…</c>, INSIDE the operator's
+    /// repository. An operator who has trusted their own repo therefore gives every hosted Gemini agent
+    /// inherited trust over the checked-out branch's configuration — and passing <c>--skip-trust</c> does
+    /// not withdraw it. Measured, not assumed.</para>
+    ///
+    /// <para><b>Which is why the MCP allowlist is here.</b> Under inherited trust, a repository-authored
+    /// <c>.gemini/settings.json</c> MCP server WAS observed starting on the ACP path — repo-controlled
+    /// process execution under the daemon user, before the model acts, on a branch that may be
+    /// contributor-authored. <see cref="GeminiNoMcpSentinel"/> reduces the allowlist to nothing, which
+    /// blocks it. Repo-authored <i>hooks</i> were separately measured NOT to run on the ACP path (they do
+    /// on the <c>--prompt</c> path — the two paths differ, and neither predicts the other).</para>
+    ///
+    /// <para><b>The sentinel is only correct while <see cref="AcpVendorDescriptor.SupportsMcpServers"/> is
+    /// false.</b> It permits nothing, so with nothing injected it costs nothing. The day the stdio
+    /// call-level probe flips that flag, this list must become the injected server names in the SAME
+    /// change, or hosted Gemini ships with MCP silently broken. <c>AcpVendorDescriptorTests</c> asserts
+    /// the coupling so the two cannot drift apart.</para>
+    ///
+    /// <para><see cref="NoOpModelSelector"/> for the same reason as Kiro: <c>session/new</c> does return a
+    /// <c>models</c> object, so <see cref="ConfigOptionModelSelector"/>'s read half would fit, but its
+    /// write half is unverified on Gemini and that selector fails SILENTLY — a session that reports the
+    /// requested model while running another. <c>ResolveDefaultModel: null</c> alone is not enough,
+    /// because <c>ResolveRequestedModel</c> prioritises a per-launch model and would reach a live
+    /// selector anyway.</para>
+    ///
+    /// <para><c>--approval-mode</c> is deliberately not passed: interactive hosting should behave as the
+    /// user's own session does, and pinning <c>plan</c> would silently make hosted Gemini read-only.
+    /// <c>DiagnosticBinary</c> needs no branch — the vendor key and the binary name are both
+    /// <c>gemini</c>.</para></summary>
+    public static readonly AcpVendorDescriptor Gemini = new(
+        Vendor:              "gemini",
+        ResolveBinaryPath:   cfg => cfg.GeminiPath,
+        ResolveDefaultModel: _ => null,
+        Argv:                ["--experimental-acp", "--skip-trust",
+                              "--allowed-mcp-server-names", GeminiNoMcpSentinel],
+        UnattendedTrustArgv: [],
Evidence
The descriptor hardcodes GeminiNoMcpSentinel = "kcap-none" and passes it to
--allowed-mcp-server-names, intending it to be a permanently non-matching name. The accompanying
design spec states the allowlist works by name matching (“non-matching name permits nothing”), which
implies a repo-authored server using the same name would be permitted and bypass the clamp.

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[268-326]
docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[234-246]

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

## Issue description
Gemini is launched with `--allowed-mcp-server-names kcap-none` as a “non-matching sentinel” to block repo-authored MCP servers. Because the sentinel is a fixed public string, a repo can simply name its MCP server `kcap-none`, which would match the allowlist and be permitted to start, re-opening the repo-controlled process execution path.

## Issue Context
The code comments/spec explicitly rely on “non-matching name permits nothing”, but a constant string cannot be guaranteed non-matching against repository-controlled configuration.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[268-326]
- src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntimeFactory.cs[323-350]
- test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[226-282]

## Implementation direction
- Stop using a fixed sentinel value for Gemini when `SupportsMcpServers == false`.
- Generate a per-launch (or per-session) unpredictable allowlist name and pass it via argv rewrite in `BuildProcessStartInfo` (or similar spawn seam). Because no MCP servers are injected today, this random allowlist will not break functionality but will prevent a repo from pre-selecting the allowed name.
- Update tests:
 - Descriptor tests should assert presence of `--allowed-mcp-server-names` (and the coupling behavior), but should not assert an exact constant sentinel if the value becomes per-launch.
 - Add a test that proves the allowlist value is not a fixed constant when `SupportsMcpServers` is false (e.g., two builds produce different values, or it matches a GUID-like pattern).
- Keep the existing non-empty constraint (avoid the empty-string crash noted in comments/spec).

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



Remediation recommended
2. Linear IDs in spec doc 📘 Rule violation ⚙ Maintainability
Description
The new spec document includes Linear issue identifiers (e.g., AI-899) in repository text,
coupling documentation to Linear-specific numbering. This violates the requirement to avoid Linear
issue keys in comments/docs and use GitHub issue references instead when needed.
Code

docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[R1-8]

+# AI-899 — Gemini CLI as an ACP hosted agent
+
+**Status:** rev 5, 2026-07-31, against `gemini 0.53.0` and `origin/main` (`e2b2821`).
+**Implementation-ready.** §3.1a's gating trust probe has been run, re-run on the correct code path, and its
+finding is contained by a flag already in the descriptor.
+**Repository:** kurrent-io/kcap-cli
+**Parent:** AI-1399 (multi-vendor ACP hosted agents)
+**Template:** the shipped Kiro child (AI-1404) and the Copilot child (AI-1403). Read those, not the
Evidence
PR Compliance ID 5 forbids Linear issue identifiers like AI-###. The newly added spec begins with
# AI-899 — ..., introducing a Linear key into repository documentation.

CLAUDE.md: Do Not Reference Linear Issue Numbers in Code Comments
docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[1-8]

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

## Issue description
Repository documentation includes Linear issue keys (e.g., `AI-899`), which should not be referenced in repo comments/docs.

## Issue Context
Compliance requires avoiding Linear-specific identifiers; if an issue reference is necessary, use a GitHub issue/PR link or number.

## Fix Focus Areas
- docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[1-8]

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


3. Allowlist test misindexes ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
Gemini_McpAllowlist_IsCoupledToSupportsMcpServers reads argv[Array.IndexOf(...)+1] without
asserting the flag exists and has a following value, so the test can inspect the wrong element or
throw an index error instead of clearly failing on missing/invalid structure. This makes future
regressions around the allowlist flag harder to diagnose and the assertion less robust.
Code

test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[R269-274]

+    public async Task Gemini_McpAllowlist_IsCoupledToSupportsMcpServers() {
+        var descriptor = AcpVendorDescriptors.Gemini;
+        var argv       = descriptor.Argv.ToArray();
+        var allowed    = argv[Array.IndexOf(argv, "--allowed-mcp-server-names") + 1];
+
+        await Assert.That(allowed).IsNotEmpty();
Evidence
The new test uses Array.IndexOf(...)+1 indexing directly. If the flag is absent or is the last
element, this expression reads the wrong argv slot or indexes out of bounds, which is not a clear
structural assertion for a coupling guard.

test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[256-282]

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

## Issue description
The test extracts the allowlist value via `argv[Array.IndexOf(argv, "--allowed-mcp-server-names") + 1]` but doesn’t assert that the flag is present or that a value exists at `index+1`. This is an unsafe pattern that can produce misleading failures (or read argv[0]) if argv shape changes.

## Issue Context
This test is meant to be a guard against drift; it should fail loudly and precisely when the flag/value is missing.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs[256-282]

## Suggested fix
- Compute `var ix = Array.IndexOf(argv, "--allowed-mcp-server-names");`
- Assert `ix >= 0`.
- Assert `ix + 1 < argv.Length`.
- Then read `var allowed = argv[ix + 1];`.
- (Optional) Also assert the flag appears exactly once to prevent ambiguous parsing.

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


4. Verbose Gemini descriptor comments 📘 Rule violation ⚙ Maintainability
Description
The PR adds very long explanatory XML doc comments (multi-paragraph rationale and operational
guidance) directly in production code, reducing readability and increasing maintenance burden. This
violates the guideline to keep comments minimal and prefer self-explanatory code with concise,
necessary comments.
Code

src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[R280-315]

+    /// <summary>Google Gemini CLI as an ACP hosted agent (<c>gemini --experimental-acp</c>).
+    /// Interactive hosting only; the unattended reviewer is its own issue, as with Kiro.
+    ///
+    /// <para><b><c>--skip-trust</c> is required, and is NOT a containment measure.</b> Gemini refuses a
+    /// headless turn in an untrusted directory outright — <c>exit 55</c> before any model call — and a
+    /// daemon-created worktree cannot be assumed pre-trusted, so without the flag every launch fails.
+    /// What it does NOT do is protect anything: trust INHERITS from a trusted parent directory, and a
+    /// daemon worktree lives at <c>&lt;repo&gt;/.capacitor/worktrees/agent-…</c>, INSIDE the operator's
+    /// repository. An operator who has trusted their own repo therefore gives every hosted Gemini agent
+    /// inherited trust over the checked-out branch's configuration — and passing <c>--skip-trust</c> does
+    /// not withdraw it. Measured, not assumed.</para>
+    ///
+    /// <para><b>Which is why the MCP allowlist is here.</b> Under inherited trust, a repository-authored
+    /// <c>.gemini/settings.json</c> MCP server WAS observed starting on the ACP path — repo-controlled
+    /// process execution under the daemon user, before the model acts, on a branch that may be
+    /// contributor-authored. <see cref="GeminiNoMcpSentinel"/> reduces the allowlist to nothing, which
+    /// blocks it. Repo-authored <i>hooks</i> were separately measured NOT to run on the ACP path (they do
+    /// on the <c>--prompt</c> path — the two paths differ, and neither predicts the other).</para>
+    ///
+    /// <para><b>The sentinel is only correct while <see cref="AcpVendorDescriptor.SupportsMcpServers"/> is
+    /// false.</b> It permits nothing, so with nothing injected it costs nothing. The day the stdio
+    /// call-level probe flips that flag, this list must become the injected server names in the SAME
+    /// change, or hosted Gemini ships with MCP silently broken. <c>AcpVendorDescriptorTests</c> asserts
+    /// the coupling so the two cannot drift apart.</para>
+    ///
+    /// <para><see cref="NoOpModelSelector"/> for the same reason as Kiro: <c>session/new</c> does return a
+    /// <c>models</c> object, so <see cref="ConfigOptionModelSelector"/>'s read half would fit, but its
+    /// write half is unverified on Gemini and that selector fails SILENTLY — a session that reports the
+    /// requested model while running another. <c>ResolveDefaultModel: null</c> alone is not enough,
+    /// because <c>ResolveRequestedModel</c> prioritises a per-launch model and would reach a live
+    /// selector anyway.</para>
+    ///
+    /// <para><c>--approval-mode</c> is deliberately not passed: interactive hosting should behave as the
+    /// user's own session does, and pinning <c>plan</c> would silently make hosted Gemini read-only.
+    /// <c>DiagnosticBinary</c> needs no branch — the vendor key and the binary name are both
+    /// <c>gemini</c>.</para></summary>
Evidence
PR Compliance ID 4 requires minimal comments. The added XML documentation for
AcpVendorDescriptors.Gemini is a multi-paragraph narrative (trust behavior, MCP containment,
coupling rules, model selection rationale), which is far beyond brief/necessary commentary.

CLAUDE.md: Keep Comments Minimal; Prefer Self-Explanatory Code
src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[280-315]

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

## Issue description
New production-code comments are overly verbose, embedding extensive design/spec narrative where concise documentation should suffice.

## Issue Context
Long rationale blocks make code harder to scan and keep correct over time; the deeper reasoning can live in the spec/README, while code should retain only the minimal invariants required for correct maintenance.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[280-315]

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


Qodo Logo

Comment on lines +280 to +315
/// <summary>Google Gemini CLI as an ACP hosted agent (<c>gemini --experimental-acp</c>).
/// Interactive hosting only; the unattended reviewer is its own issue, as with Kiro.
///
/// <para><b><c>--skip-trust</c> is required, and is NOT a containment measure.</b> Gemini refuses a
/// headless turn in an untrusted directory outright — <c>exit 55</c> before any model call — and a
/// daemon-created worktree cannot be assumed pre-trusted, so without the flag every launch fails.
/// What it does NOT do is protect anything: trust INHERITS from a trusted parent directory, and a
/// daemon worktree lives at <c>&lt;repo&gt;/.capacitor/worktrees/agent-…</c>, INSIDE the operator's
/// repository. An operator who has trusted their own repo therefore gives every hosted Gemini agent
/// inherited trust over the checked-out branch's configuration — and passing <c>--skip-trust</c> does
/// not withdraw it. Measured, not assumed.</para>
///
/// <para><b>Which is why the MCP allowlist is here.</b> Under inherited trust, a repository-authored
/// <c>.gemini/settings.json</c> MCP server WAS observed starting on the ACP path — repo-controlled
/// process execution under the daemon user, before the model acts, on a branch that may be
/// contributor-authored. <see cref="GeminiNoMcpSentinel"/> reduces the allowlist to nothing, which
/// blocks it. Repo-authored <i>hooks</i> were separately measured NOT to run on the ACP path (they do
/// on the <c>--prompt</c> path — the two paths differ, and neither predicts the other).</para>
///
/// <para><b>The sentinel is only correct while <see cref="AcpVendorDescriptor.SupportsMcpServers"/> is
/// false.</b> It permits nothing, so with nothing injected it costs nothing. The day the stdio
/// call-level probe flips that flag, this list must become the injected server names in the SAME
/// change, or hosted Gemini ships with MCP silently broken. <c>AcpVendorDescriptorTests</c> asserts
/// the coupling so the two cannot drift apart.</para>
///
/// <para><see cref="NoOpModelSelector"/> for the same reason as Kiro: <c>session/new</c> does return a
/// <c>models</c> object, so <see cref="ConfigOptionModelSelector"/>'s read half would fit, but its
/// write half is unverified on Gemini and that selector fails SILENTLY — a session that reports the
/// requested model while running another. <c>ResolveDefaultModel: null</c> alone is not enough,
/// because <c>ResolveRequestedModel</c> prioritises a per-launch model and would reach a live
/// selector anyway.</para>
///
/// <para><c>--approval-mode</c> is deliberately not passed: interactive hosting should behave as the
/// user's own session does, and pinning <c>plan</c> would silently make hosted Gemini read-only.
/// <c>DiagnosticBinary</c> needs no branch — the vendor key and the binary name are both
/// <c>gemini</c>.</para></summary>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Verbose gemini descriptor comments 📘 Rule violation ⚙ Maintainability

The PR adds very long explanatory XML doc comments (multi-paragraph rationale and operational
guidance) directly in production code, reducing readability and increasing maintenance burden. This
violates the guideline to keep comments minimal and prefer self-explanatory code with concise,
necessary comments.
Agent Prompt
## Issue description
New production-code comments are overly verbose, embedding extensive design/spec narrative where concise documentation should suffice.

## Issue Context
Long rationale blocks make code harder to scan and keep correct over time; the deeper reasoning can live in the spec/README, while code should retain only the minimal invariants required for correct maintenance.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs[280-315]

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, with reasons — leaving this thread open for a maintainer to overrule. The density matches the sibling descriptors (Kiro explains why SupportsMcpServers: true where Copilot is false on the same advertised shape), and each paragraph records something measured that the code cannot show — two of them are why this change is correct rather than dangerous: --skip-trust is required and is not containment (trust inherits from a parent; the flag does not withdraw it), and the deny-all name must be unguessable — which is the bug you found in comment 3690901406. Deleting that explanation is how it comes back. Full rationale: #421 (comment)

🤖 Addressed by Claude Code

Comment on lines +1 to +8
# AI-899 — Gemini CLI as an ACP hosted agent

**Status:** rev 5, 2026-07-31, against `gemini 0.53.0` and `origin/main` (`e2b2821`).
**Implementation-ready.** §3.1a's gating trust probe has been run, re-run on the correct code path, and its
finding is contained by a flag already in the descriptor.
**Repository:** kurrent-io/kcap-cli
**Parent:** AI-1399 (multi-vendor ACP hosted agents)
**Template:** the shipped Kiro child (AI-1404) and the Copilot child (AI-1403). Read those, not the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Linear ids in spec doc 📘 Rule violation ⚙ Maintainability

The new spec document includes Linear issue identifiers (e.g., AI-899) in repository text,
coupling documentation to Linear-specific numbering. This violates the requirement to avoid Linear
issue keys in comments/docs and use GitHub issue references instead when needed.
Agent Prompt
## Issue description
Repository documentation includes Linear issue keys (e.g., `AI-899`), which should not be referenced in repo comments/docs.

## Issue Context
Compliance requires avoiding Linear-specific identifiers; if an issue reference is necessary, use a GitHub issue/PR link or number.

## Fix Focus Areas
- docs/superpowers/specs/2026-07-31-ai899-gemini-acp-hosted-agent-design.md[1-8]

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined — I think the rule is being over-applied here; leaving this thread open to adjudicate. The cited rule is about code comments, and this repo enforces it with scripts/check-linear-ids.sh, which greps exactly :(glob)src/**/*.cs and :(glob)test/**/*.cs. It passes on this branch, and I did fix a real violation earlier in this PR (an AI-899 I had put in a test comment). Design specs are the opposite case — every spec in docs/superpowers/specs/ is named and titled for its issue (…ai1404-kiro-acp-hosted-agent-design.md, …ai1410-…, …ai1463-…), and a spec whose whole job is to be the durable record of an issue should be findable from it. Changing that is a repo-wide convention decision across ~30 files, not a call for this PR.

🤖 Addressed by Claude Code

Comment thread src/Capacitor.Cli.Daemon/Acp/AcpVendorDescriptor.cs
Comment thread test/Capacitor.Cli.Tests.Unit/Acp/AcpVendorDescriptorTests.cs
Codex round 1, finding 1, and it was the whole clamp. The descriptor passed the
literal `kcap-none` to --allowed-mcp-server-names and a comment asserted it was
"a name no MCP server will ever have". A contributor controls the reviewed
branch's .gemini/settings.json and can simply name their server `kcap-none`.
MEASURED: it executes. The clamp was bypassed completely, and the comment was
asserting a property nothing enforced — the same semantic-label-is-not-validation
error this PR already fixes elsewhere.

The descriptor now carries a PLACEHOLDER and the factory substitutes an
unguessable `kcap-deny-{guid}` per launch, so two concurrent agents do not even
share one. Verified against the worst case: a repo naming its server the
placeholder itself is BLOCKED. The substitution is deliberately generic — any
vendor whose containment is "allow a name that cannot exist" needs it, and a
per-vendor copy of the reasoning is how one of them ends up with a guessable
literal again.

Finding 3, also real: the KEY is interpolated into the same `set "K=V"` line and
is exactly as dangerous as the value, but only the value was checked. The stated
rationale for validating at the sink applies to both sides equally. Keys
containing a quote, newline or `=`, and empty keys, are now rejected, with tests.

Finding 2: the coupling test was vacuous two ways. With the option ABSENT,
IndexOf returns -1 and `argv[IndexOf + 1]` read argv[0] — non-empty and unequal
to the sentinel, so the coupling "held" with no allowlist at all. And a second
occurrence, whose value may win at argument parsing, was invisible. Now asserts
exactly one occurrence, a non-negative index, and a following value.

Finding 4: the auth-hint test asserted a few required phrases and two forbidden
ones, which cannot establish "does not diagnose" — a hint could keep both hedges
and append a categorical verdict. Now a golden equality test against the approved
wording held independently in the test.

AcpVendorDescriptorTests 19/19 · WindowsTaskUnitRepresentabilityTests 17/17 ·
ServiceEnvironmentTests 10/10 · AcpHostedAgentRuntimeFactoryTests 68/68.
…t Windows

Codex round 2, and systemd was the worst of the three. A key shaped like

    SAFE=ok\nExecStartPre=/bin/touch /tmp/pwned\n#

with a perfectly ordinary value renders a valid `Environment=SAFE=ok` line, then
an attacker-chosen ExecStartPre= directive that the service runs on EVERY
restart, then comments out the remainder. Value escaping cannot help:
SystemdValue normalises the value and EnvAssignment decides quoting from the
value — neither looks at the name. Round 1 fixed exactly this at the Windows
wrapper and I left the same primitive live in a service definition that executes.

The check is now one shared rule, ServiceText.RequireValidEnvName, called by all
three writers. Deliberately an ALLOWLIST — the POSIX name grammar
[A-Za-z_][A-Za-z0-9_]* — rather than a blacklist of the characters each format
happens to fear: a blacklist has to be right once per format and again for the
next writer, while a grammar for what a legitimate name can contain is right by
construction. Nothing on any capture allowlist is excluded, so a rejection means
a caller composed the dictionary directly, which is the path that bypasses
ServiceEnvironment in the first place.

launchd is included even though XML escaping already contains the name
structurally: a control character is not legal XML 1.0, so the plist would be
silently unparseable rather than injected. Same grammar, every sink.

Tests call each writer directly rather than testing the helper once — the point
of a sink check is that every sink has it, and a shared-helper test would pass
while a writer forgot to call it. Positive control at all three sinks over the
names kcap actually captures, so the suite cannot pass on a writer that rejects
everything.

Mutation-proven: removing the systemd guard fails exactly its 6 hostile-name
cases and nothing else.

UnitWriterEnvNameTests 19/19 · LaunchdUnitTests 6/6 · ServiceFilesTests 12/12 ·
ServiceEnvironmentTests 10/10 · WindowsTaskUnitRepresentabilityTests 17/17 ·
AcpVendorDescriptorTests 19/19.
Codex round 3. The name check landed but the value still went straight to
ServiceText.Xml, and entity escaping does not help: U+0001 has no legal XML 1.0
form, escaped or not, while POSIX permits any byte except NUL in a value. So a
captured or directly composed value could produce a plist launchctl refuses to
load.

The consequence is availability rather than injection — but the failure mode is
bad: the service silently does not exist. Failing at write time names the
variable instead.

Tab, LF and CR are legal XML 1.0 and are explicitly still accepted; the check is
about what XML cannot carry, not tidiness. Both directions tested.

Written as a plain scan rather than FirstOrDefault: that returns '\0' for "no
match", which is itself an illegal character here, so the sentinel and a real hit
are indistinguishable without a second pass. The first version did exactly that
and worked, but subtlety in a security-adjacent guard is not worth the brevity.

UnitWriterEnvNameTests 24/24.
@realtonyyoung

Copy link
Copy Markdown
Collaborator Author

Qodo review — both bugs resolved, both rule flags declined with reasons

🐞 Gemini MCP clamp bypass — ✓ Resolved (b1a8f98)

Qodo and the codex reviewer found this independently, and it was the whole clamp. Confirmed by measurement before fixing: a repo naming its MCP server kcap-none executes on the ACP path under inherited trust. My comment claimed "a name no MCP server will ever have" — asserting a property nothing enforced.

The descriptor now carries a placeholder and the factory substitutes an unguessable kcap-deny-{guid} per launch. Verified against the worst case: a repo naming its server the placeholder itself is blocked, while an ACP-injected server still loads.

🐞 Allowlist test misindexes — ✓ Resolved (b1a8f98)

Also correct, and vacuous two ways: with the option absent, IndexOf returns −1 so argv[IndexOf + 1] read argv[0] — non-empty and unequal to the sentinel, so the "coupling" held with no allowlist at all; and a second occurrence was invisible. Now asserts exactly one occurrence, a non-negative index, and a following value.

📘 "Linear IDs in spec doc" — declining, and I think the rule is being over-applied

The cited rule is CLAUDE.md: Do Not Reference Linear Issue Numbers in Code Comments, and this repo enforces it with scripts/check-linear-ids.sh, which scans exactly two pathspecs:

git grep -nE "$ID_RE" -- ':(glob)src/**/*.cs'
git grep -nE "$ID_RE" -- ':(glob)test/**/*.cs'

C# source only. The guard passes on this branch, and I did fix a real violation earlier in this PR — an AI-899 I had put in a test comment.

Design specs are the opposite case: the convention is that they carry their issue id, in both filename and title.

2026-07-28-ai1555-agent-command-group-design.md
2026-07-29-ai1557-flow-participant-aware-agent-commands-design.md
2026-07-30-ai1404-kiro-acp-hosted-agent-design.md
2026-07-30-ai1410-kiro-unattended-reviewer-design.md
2026-07-30-ai1463-gemini-memory-adapter-design.md

Every spec in the directory. Renaming this one would make it the only spec you cannot find from its issue, and a spec's whole job is to be the durable record of that issue. If the rule is meant to extend to docs, that is a repo-wide decision affecting ~30 files and a convention, not something to change in this PR.

📘 "Verbose Gemini descriptor comments" — declining, same as on #416

Two grounds:

It matches the file's convention. The sibling descriptors carry the same density — Kiro's explains why SupportsMcpServers: true while Copilot's is false on the same advertised capability shape; Copilot's explains why borrowed review is deliberately not declared statically. Terse comments here would be the outlier.

Each paragraph records something measured that the code cannot show, and two of them are why this PR is correct rather than dangerous:

  • --skip-trust is required and is not containment — trust inherits from a parent, the flag does not withdraw it. Without that sentence the next person reads the flag as a security control.
  • the deny-all name must be unguessable — a fixed one was tried, and a repo can name its server to match it. That is the bug Qodo just found; deleting the explanation is how it comes back.
  • hooks do not execute on the ACP path but do on --prompt — the paths differ, and an earlier revision of this design reached the wrong conclusion by generalising from one of them.

Happy to be overruled on a specific paragraph that reads as noise rather than as a trap marker.


Codex code-review flow is at round 4 (rounds 1–3: the clamp bypass, the unvalidated env key at the Windows sink, the same key primitive at the systemd sink — where it injects an ExecStartPre= that runs on every restart — and XML-unrepresentable launchd values). All fixed and mutation-proven.

… parsing

Both writers interpolate values into a format that expands them BEFORE reading
them as data, and each had the escape applied unevenly:

- systemd expands %-specifiers in Environment=, Description= AND ExecStart=.
  SystemdValue escaped newlines but not `%`, and QuoteArg escaped nothing, so a
  path under /home/50%off rendered an ExecStart systemd rewrites or refuses to
  load. BinaryFromUnit reverses the doubling so `daemon doctor` still resolves
  the real path.
- cmd.exe expands %VAR% in a batch file. CmdValue was applied to the env keys,
  the env values and the binary path — but not to the log path or extra args on
  that same exec line. `%` is a legal Windows filename character, so the log
  path could be rewritten from the daemon's own environment.

Also replaces the plist value guard's char.IsControl with XmlConvert's XML 1.0
predicate. The two are not equivalent in either direction: IsControl accepted
U+FFFE/U+FFFF and lone surrogates (which no encoder can represent, so the value
is corrupted or throws) and rejected U+007F-U+009F (which XML 1.0 permits, so an
install failed over a representable value). The scan now consumes a valid
surrogate pair as one character, so a value containing an emoji is accepted.

Found by the code-review flow, which reasoned from the submitted description
rather than the source and so also raised the already-closed cmd env-value case;
checking that claim against CmdValue is what surfaced the unescaped neighbours
on the same line.

35 new tests: preservation and round-trip for both formats, the structural
%PAYLOAD% case, and XML boundary cases (U+0085, U+FFFD, U+FFFE, U+FFFF, lone
and reversed surrogates, a trailing high surrogate, consecutive pairs).
…environment

Same asymmetry as the previous commit, at the third sink. Plist() guarded
environment values with RequireXmlRepresentableValue but sent the service label,
ProgramArguments and StandardOut/ErrorPath straight to ServiceText.Xml.

SecurityElement.Escape handles the five markup characters and nothing else, so a
C0 control, U+FFFE or a lone surrogate passed through unaltered into a plist
launchctl then silently refuses to load. ProgramArguments carries the binary
path, the log path and the extra args, and macOS filenames may contain any byte
except / and NUL — so this was reachable through an ordinary directory name.

Every interpolated string now goes through one guard-then-escape helper.

5 tests: a control character in the binary path, the log path, an extra arg, and
(regression) an environment value, plus a positive control proving a plist whose
values are all legal — including an emoji in the log path — still renders.
…idate --max-agents

Percent doubling handled expansion but not the surrounding grammar, and the
quote-when-it-has-a-space rule left the injection class open on both platforms.

cmd treats & | < > ( ) ^ as metacharacters OUTSIDE double quotes, so an argument
like `8&calc.exe` has neither a space nor a percent, rendered bare, and cmd ran
the tail as a second command in a file the OS executes at every logon. Every
exec-line value is now guarded and quoted unconditionally; a quote or newline is
rejected rather than escaped (neither can be represented there); and a trailing
backslash run is doubled, since under the Windows argv rules it would otherwise
escape the closing quote and merge the argument with the next.

systemd expands $NAME/${NAME} in ExecStart as well as % specifiers, so a path
containing ${HOME} was replaced from the daemon's own environment -- in the
executable path. Both are now doubled and reversed by BinaryFromUnit. NOT applied
to Environment=, where systemd expands specifiers but not variables, so doubling
$ would corrupt the value; the two sinks differ, so they get different escapes.
A bare `;` ExecStart argument is refused: it is systemd's command separator, and
refusing is provably safe where relying on this code's reading of the documented
\; escape (unverifiable on the build machines) is not. A line break is refused
for the same reason quoting cannot fix it -- the directive ends at the newline.

Also narrows the source: --max-agents is the only caller-supplied ExtraArgs
entry, arrives unparsed off the command line, and is numeric -- so it is now
validated as a positive integer instead of only escaped where it lands. The two
defences are independent.

29 tests: six cmd metacharacters, three unrepresentable arguments, an
unrepresentable log path, trailing vs interior backslashes, ${HOME} and $HOME in
both the binary path and an argument, both round-trips, the Environment= arm
proving $ is NOT doubled there, a bare vs embedded semicolon, CR and LF, and
seven rejected --max-agents values plus two accepted.
…exer gaps

Delayed expansion: the wrapper began with only `@echo off` and the action was a
plain `cmd /c`. Delayed expansion is off by default but can be enabled for every
cmd session via the Command Processor registry key, and `!NAME!` expands INSIDE
double quotes -- so a value like `!PAYLOAD!` passed both the quoting and CmdValue
(which doubles %, not !) and could expand after serialization. The mode is now
part of the artifact: `setlocal DisableDelayedExpansion` as the wrapper's first
statement, and /v:off on the action so it is fixed before the file is opened.

cmd /c quoting: the action was `/c "<wrapper>"`, which relies on cmd's
conditional quote-stripping. A path containing & -- legal in a Windows account
name, and this path runs through the config directory -- fails those conditions,
the quotes come off, and the metacharacter is parsed as command syntax. Now
`/d /s /v:off /c ""<wrapper>""`: /s with a command both starting and ending in a
quote strips exactly the outer pair and takes the rest verbatim, /d skips AutoRun
so a per-user AutoRun value cannot inject ahead of the daemon. A % in the wrapper
path is refused rather than escaped -- %% is a batch-file construct with no
command-line equivalent, so refusal is the only correct answer at that sink,
while the wrapper BODY still escapes. Same character, two sinks.

systemd: NeedsQuote covered the double quote and backslash but not the
apostrophe, which is equally structural to systemd's word lexer. Bare, an
unpaired one opens a single-quoted string that never closes (unit unloadable);
a paired one is stripped (value silently changed). `O'Reilly` in a home
directory is the ordinary case.

14 tests: the setlocal and its position, a bang-shaped value, the switch set, the
nested-quote form, a path with a space and an ampersand, a %USERNAME%-shaped
component, two structurally-unrepresentable paths, and apostrophes across
ExecStart, Environment= and the round-trip -- paired as well as unpaired.
…e Task XML

Two more instances of the two patterns this review has been finding.

systemd rejected CR/LF at ExecStart but emitted every other control character
verbatim, and SystemdValue rewrote CR/LF in an Environment= value to a space. A
POSIX environment value, a filename and an argv string may all legally carry
U+0001, a backspace or a vertical tab; quoting makes none of them valid unit
syntax and this writer has no encoder for them. One shared guard now refuses C0
and DEL at both Environment= and ExecStart=. systemd does document C-style and
hex escapes, but nothing here can exercise a real systemd parser, so this refuses
the input rather than shipping an escape whose behaviour it has only read about --
the same call already made for the bare ';' separator.

That supersedes the newline-to-space normalisation, which is the better outcome
independently: silently rewriting a caller's value leaves a service running with
a value nobody chose, which is harder to diagnose than an install that failed and
named the variable.

Task XML: TaskXml interpolated the wrapper path and the service id through
ServiceText.Xml with no representability check -- the plist's fix, one sink over.
Composition does not imply XML validity: the path carries KCAP_CONFIG_DIR
verbatim, Windows paths are native UTF-16, and U+FFFE/U+FFFF or a malformed
surrogate unit is outside XML 1.0 while SecurityElement.Escape passes all of them
through. Both values now route through a guard-then-escape helper mirroring the
plist's, including the sanitized service id, so the invariant holds at every
interpolation rather than only where it is currently needed.

17 tests: six control characters in an environment value, four at ExecStart, one
in an extra arg, a positive control that an ordinary unit still renders, four
XML-illegal wrapper paths, and a positive control that a supplementary character
in the path is accepted. The line-break case keeps its own test and now asserts
the code point the shared guard names.
@realtonyyoung

Copy link
Copy Markdown
Collaborator Author

/review

@qodo-code-review

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Warning

/review is deprecated. Use /agentic_review instead (removal date not yet scheduled).

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

Rev 5 documented two things that turned out to be defects, so the spec is now
wrong in a way that would mislead the next reader of these sinks.

3.1d: the descriptor block still showed the literal `kcap-none` deny-all MCP name
with a comment calling it "a name no MCP server will ever have". Nothing enforced
that, and a repo naming its server `kcap-none` was measured to execute. Records
the measurement, the fix (placeholder plus per-launch unguessable name), and that
the clamp is now verified in BOTH directions rather than only the negative one.

4.2b: 4.2 reasoned about environment values one writer at a time, which was the
wrong unit. Each writer interpolates several values into the same artifact, and in
every one of them some were guarded and their neighbours were not — six sinks,
tabulated. Also records the second error: escaping the one expansion a format
performs without checking what else it expands (cmd's metacharacters outside
quotes, delayed expansion inside them, conditional /c quote-stripping; systemd's
$-substitution in ExecStart and the apostrophe in its word lexer), the
same-character-two-sinks rule that falls out of it, and the escape-where-verifiable
/ refuse-otherwise policy. Supersedes 4.2's decision to leave the CR/LF rewrite in
place: a silent rewrite is worse than a refusal.

Also reorders 4.2b after 4.2a, and notes the mutation-restore trap (verify by
content, not by git diff — a diff can hide a bad restore through alignment).
@realtonyyoung

Copy link
Copy Markdown
Collaborator Author

/agentic_review

Comment on lines +22 to +139
/// <summary>
/// Escape a systemd <c>Environment=</c>/<c>Description=</c> value: literal percent signs doubled.
///
/// <para><b>Why the doubling.</b> systemd expands <i>specifiers</i> — <c>%n</c>, <c>%h</c>, <c>%i</c> and
/// friends — in both of those directives, so a value carrying a literal <c>%</c> does not survive: a
/// <c>PATH</c> containing <c>%n</c> silently becomes the unit name, and a percent sequence systemd does
/// not recognise makes it refuse to load the unit at all. <c>%%</c> is systemd's own escape for one
/// literal percent, so this is a faithful round-trip rather than a mangling.</para>
///
/// <para>This is the same primitive <see cref="CmdValue"/> applies for cmd.exe, for the same reason, and
/// it was missing here while present there — the asymmetry is what review caught.</para>
///
/// <para>It no longer normalises CR/LF to spaces: <see cref="RequireNoControlCharacters"/> refuses them
/// at the sink first, so the replacement was unreachable — and silently rewriting a caller's value was
/// the wrong behaviour anyway.</para>
/// </summary>
public static string SystemdValue(string value) => value.Replace("%", "%%");

/// <summary>
/// Rejects a VALUE that XML 1.0 cannot represent, for the plist writer.
///
/// <para>Entity escaping does not help here, and that is the point: <c>SecurityElement.Escape</c> turns
/// <c>&amp;</c> and <c>&lt;</c> into entities, but U+0001 has no legal XML 1.0 representation at all —
/// escaped or not. XML 1.0 permits only #x9, #xA, #xD and #x20 upwards. POSIX environment values, by
/// contrast, may contain any byte except NUL, so a captured or directly composed value really can carry
/// one.</para>
///
/// <para>The consequence is availability rather than injection: the plist becomes unparseable, so
/// <c>launchctl</c> refuses to load it and the service cannot install or restart. Failing at write time
/// names the variable; failing at load time produces a service that silently does not exist.</para>
/// </summary>
public static void RequireXmlRepresentableValue(string name, string value) {
// The predicate is XmlConvert's, not char.IsControl — the two are NOT equivalent, and review caught
// both directions of the gap. char.IsControl accepts U+FFFE, U+FFFF and lone surrogates, none of
// which XML can carry (a lone surrogate is not even a scalar value, so the encoder either throws or
// substitutes), and it rejects U+007F–U+009F, which XML 1.0 permits. Deferring to the platform
// predicate makes the code match the doc comment above instead of approximating it.
//
// Scanned by index rather than foreach so a valid surrogate PAIR can be recognised as the one
// supplementary character it encodes; checking `char` in isolation would reject every emoji.
for (var i = 0; i < value.Length; i++) {
var c = value[i];

if (char.IsHighSurrogate(c) && i + 1 < value.Length
&& XmlConvert.IsXmlSurrogatePair(value[i + 1], c)) {
i++; // consumed the low half too
continue;
}

if (XmlConvert.IsXmlChar(c)) continue;

throw new InvalidOperationException(
$"Refusing to write a service unit: the value of '{name}' contains U+{(int)c:X4}, which "
+ "XML 1.0 cannot represent even escaped — the resulting plist would not load. Unset or "
+ "correct that variable, then re-run `kcap daemon service install`.");
}
}

/// <summary>
/// Rejects an environment-variable NAME that no unit writer can carry safely. Shared by all three
/// sinks, because every one of them interpolates the name into a line whose structure the name can
/// break — and each format then hands the attacker something different.
///
/// <para><b>systemd is the worst of the three, which is why this is central rather than per-writer.</b>
/// A key shaped like <c>SAFE=ok\nExecStartPre=/bin/touch /tmp/pwned\n#</c> with an ordinary value
/// renders a valid <c>Environment=SAFE=ok</c> line, then an attacker-chosen <c>ExecStartPre=</c>
/// directive, then comments out the remainder — a command the service runs on every restart. Value
/// escaping does not help: <c>SystemdValue</c> normalises the VALUE, and <c>EnvAssignment</c> decides
/// quoting from the VALUE. Neither looks at the name.</para>
///
/// <para>The rule is the POSIX environment-name grammar (<c>[A-Za-z_][A-Za-z0-9_]*</c>) rather than a
/// blacklist of the characters each format happens to fear. A blacklist has to be right three times,
/// once per format, and a fourth writer would need it extended; an allowlist of what a legitimate name
/// can contain is right by construction. Nothing on any capture allowlist is excluded by it, so a
/// rejection means a caller composed the dictionary directly — which is exactly the path that bypasses
/// <see cref="ServiceEnvironment"/>.</para>
/// </summary>
public static void RequireValidEnvName(string name) {
var ok = name.Length > 0
&& (char.IsAsciiLetter(name[0]) || name[0] == '_')
&& name.All(c => char.IsAsciiLetterOrDigit(c) || c == '_');

if (ok) return;

throw new InvalidOperationException(
$"Refusing to write a service unit: '{name}' is not a valid environment variable name "
+ "([A-Za-z_][A-Za-z0-9_]*). A name outside that grammar can break the unit's own line "
+ "structure — in a systemd unit it can inject an ExecStartPre= directive that runs on every "
+ "restart. No name from the capture allowlist is affected, so this one was added directly.");
}

/// <summary>
/// Rejects a value carrying a C0 control character or DEL, for the systemd writer.
///
/// <para>Quoting does not make a raw control byte valid unit syntax, and this writer has no encoder for
/// one: <see cref="SystemdValue"/> handles expansion, <c>Esc</c> handles the backslash and the double
/// quote, and neither touches U+0001, a backspace or a vertical tab — all of which a POSIX environment
/// value, a filename or an argv string may legally contain. systemd does document C-style and hex
/// escapes, but this code has no way to exercise a real systemd parser, so it refuses the input rather
/// than shipping an escape whose behaviour it has only read about. That is the same call already made for
/// the bare <c>;</c> separator, and the same reason.</para>
///
/// <para>CR and LF are included, which supersedes the old newline-to-space normalisation in
/// <see cref="SystemdValue"/>: silently rewriting a value the caller supplied is worse than declining to
/// write it, because a service that runs with a value nobody chose is harder to diagnose than an install
/// that failed and said which variable was at fault.</para>
/// </summary>
public static void RequireNoControlCharacters(string name, string value) {
foreach (var c in value) {
if (!char.IsControl(c) || c > '\u007F') continue;

throw new InvalidOperationException(
$"Refusing to write a systemd unit: the value of '{name}' contains the control character "
+ $"U+{(int)c:X4}. A unit directive has no encoding for one that this code can verify, and "
+ "quoting does not make it valid syntax — systemd would refuse to load the unit. Unset or "
+ "correct that variable, then re-run `kcap daemon service install`.");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Verbose servicetext doc comments 📘 Rule violation ⚙ Maintainability

The PR introduces multi-paragraph explanatory comments in ServiceText that significantly exceed
concise documentation and duplicate information that could be captured via clearer naming/factoring.
This increases maintenance burden and reduces readability, violating the “keep comments minimal”
requirement.
Agent Prompt
## Issue description
`ServiceText` contains very long, multi-paragraph comments that reduce readability and increase future maintenance cost.

## Issue Context
PR Compliance requires keeping comments minimal and preferring self-explanatory code; these comments are substantially longer than necessary for API docs.

## Fix Focus Areas
- src/Capacitor.Cli/Services/ServiceText.cs[22-139]

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, consistently with the same flag on AcpVendorDescriptor — leaving this thread open for a maintainer to overrule.

ServiceText is the file where this PR's security reasoning lives, and the comments are load-bearing in a specific way: each records a measured fact about a target parser that the code cannot express, and in several cases why the obvious simpler thing is wrong. RequireXmlRepresentableValue explains why the predicate is XmlConvert's and not char.IsControl — the two differ in both directions, which is a bug review caught. SystemdValue explains why % is doubled but $ is deliberately not, while QuoteArg doubles both — an asymmetry that looks like an oversight and is not. RequireNoControlCharacters records that the escape exists but is refused because nothing here can exercise a real systemd parser.

Delete those and the next reader's reasonable simplification reintroduces the defect. Naming and factoring cannot carry "systemd expands specifiers in Environment= but not variables."

🤖 Addressed by Claude Code

Comment thread src/Capacitor.Cli/Services/WindowsTaskUnit.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a2dec46

Inserting a new member above an existing one detached the existing member's
doc comment, leaving two back-to-back <summary> elements that the compiler
concatenates into a single doc comment on whichever member follows. So
`Guarded` carried RequireSafeWrapperPath's rationale and RequireSafeWrapperPath
carried none; likewise SubstituteUnmatchableNames and ResolveRequestedModel.
Mis-attributed docs in IDE tooltips and anything generating XML output.

Found by Qodo. The same slip had already been caught once in ServiceText during
this branch, so this adds a repo-wide check rather than a third one-off fix:
grepping for `/// </summary>` immediately followed by `/// <summary>` finds
every instance. Five remain in ImportCommand and CodexHookCommand, both
untouched by this PR and left alone deliberately rather than widening a
review-clean diff for a cosmetic issue in unrelated files.

Also merges the two AcpVendorDescriptorTests blocks, which described the same
golden test, into one summary.
@realtonyyoung
realtonyyoung merged commit bfb7bb2 into main Jul 31, 2026
6 checks passed
@realtonyyoung
realtonyyoung deleted the tonyyoung/ai-899-gemini-hosting branch July 31, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant