fix(security): pin the auto-update kiro-cli spawn off PATH - #7964
fix(security): pin the auto-update kiro-cli spawn off PATH#7964iamwhatever wants to merge 1 commit into
kiro-cli spawn off PATH#7964Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Real PATH-hijack on an unattended spawn, closed with the exact fail-closed pattern its sibling git and wheel paths already use; the availability trade is weighed and disclosed. [DESIGN-REVIEWED] 10bef45 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. I have what I need to write the review. First-Principles-Verdict: CONCERNS The pin is derived and reuses existing mechanisms, but the sibling count is short: the identical What this change shipsIntent: stop a planted
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 10bef45 |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/slack/gateway.py:9659 -- Node injection survives the pinned environment |
`_auto_apply_update` spawned the optional backend update as the BARE
NAME `"kiro-cli"` with no `env=`, using `shutil.which` only as an
existence check and discarding the path it resolved. `execvp` therefore
re-resolved the name off `PATH` at spawn time.
Per `trusted_system_bin`'s own docstring a gateway's `PATH` can lead with
agent-writable directories (a worktree venv's `bin`, `~/.local/bin`), so
anything able to drop an executable named `kiro-cli` ahead of the real
install got arbitrary code execution as the gateway user. This is the
UNATTENDED path: `stdout`/`stderr` are `DEVNULL` and the handler
downgrades failure to `logger.debug`, so the run leaves no trace.
Resolve through `platform_compat.trusted_system_bin("kiro-cli")`, which
probes the fixed system directories and nothing else, and pass the
`_trusted_path_env()` this file's wheel path already builds, which
narrows the child's `PATH` to those directories and drops the
interpreter/loader variables. Both are refusals: no binary in a trusted
directory, or no trusted `PATH`, skips the step rather than falling back
to a bare name.
Deliberately NOT `resolve_kiro_cli`, the resolver the ACP launch path
uses. That one reads `KIROCREW_KIRO_BIN` and `~/.local/bin` FIRST and
both are agent-writable, so it closes only the PATH-ordering half of the
hole and leaves the planted-binary half open on the one path that runs
unattended. The cost of the stricter resolver is real and accepted:
kiro-cli is a per-user install, so where it lives outside the system
directories this step becomes a logged skip and the operator runs
`kiro-cli update` themselves. That is the trade `_shell_exec_args` and
`_trusted_path_env` already make here, and it costs an optional backend
refresh, never the gateway's own update.
This completes the hardening #5387 applied to the seven `git` spawns in
this same function (`trusted_git_bin()` + `git_command_env()`, roughly
430 lines above) and left off this one call site. #5387 also widened the
stage gate from `branch != "mainline"` to `is_primary_branch(branch)`:
the repo default branch is `main`, so this code became reachable on a
normal checkout in that same change.
Tests assert the SPAWN, not the source — argv[0] is the resolver's
absolute path, an explicit `env` is passed, and the step is skipped on
either refusal. The resolver stub dispatches on NAME so that stubbing
`trusted_system_bin` does not also redirect the git spawns, which resolve
through it via `trusted_git_bin`. Revert-verified: all three fail against
the pre-fix spawn (`assert 'kiro-cli' == '/usr/bin/kiro-cli'`, and a
spawn recorded where none is allowed) and pass with the fix restored.
d1c556d to
10bef45
Compare
Closing — the same six lines as #7712, and this mechanism resolves to
|
Problem / Motivation
GatewayOrchestrator._auto_apply_updateinsrc/kiro_crew/slack/gateway.pyspawned the optional backend update as the bare name"kiro-cli", withshutil.which("kiro-cli")used only as an existence check whose resolved path was thrown away, and with noenv=:create_subprocess_execre-resolves a bareargv[0]offPATHat spawn time, so thewhichanswer decided nothing.Why it matters
Per
trusted_system_bin's own docstring a gateway'sPATHcan legitimately lead with agent-writable directories (a worktree venv'sbin,~/.local/bin). Anything able to drop an executable namedkiro-cliahead of the real install got arbitrary code execution as the gateway user.This is the unattended auto-update path.
stdout/stderrareDEVNULLand the handler downgrades failure tologger.debug, so a hijacked run leaves nothing behind to notice.Two things make it pointed rather than incidental.
It completes the hardening #5387 applied to the git spawns in this same function. That PR pinned all seven
gitspawns here toplatform_compat.trusted_git_bin()+git_command_env(), with a comment explaining exactly this hazard ("a gateway'sPATHcan lead with an agent-writable directory … so a bare"git"lets a planted shim run"). The guard sits roughly 430 lines above thekiro-clispawn; that one call site was left out.The stage became reachable in that same PR. #5387 widened the branch gate from
branch != "mainline"tois_primary_branch(branch), which includes"main". This repo's default branch ismain(branchmainlinedoes not exist), so before #5387 this stage returned early on every normal checkout and never ran. It runs now.What changed
Resolve to an absolute path and pass an explicit environment, mirroring the sibling guards rather than inventing a pattern:
platform_compat.trusted_system_bin("kiro-cli")— probes the fixed system directories and ignoresPATHentirely, so neither the spawn-time re-resolution nor a planted binary can decide what runs._trusted_path_env()(kiro_crew.platform.update_provider) — the env builder this same file's wheel-update path already uses. Pinning the binary is only half of it: the child otherwise resolves its own helper words through the gateway's inheritedPATH. This narrowsPATHto the trusted system directories and drops the interpreter/loader variables.Both are treated as refusals, not as things to fall back from: no binary in a trusted directory, or no trusted
PATH, skips the step. A bare-name fallback would reinstate the exact hole. The step is optional and non-fatal, so the rest of the update still applies; the "resolved but no trustedPATH" case logs atWARNINGso the degradation is not indistinguishable from "no kiro-cli installed".Why not
resolve_kiro_cliThe first revision of this PR used
resolve_kiro_cli(the resolver the ACP launch path andcli_doctorshare) on the reasoning that kiro-cli is a per-user install. That was wrong, and the GPT 5.6 review was right to block it.resolve_kiro_cliconsultsKIROCREW_KIRO_BINand~/.local/binfirst — both agent-writable, and~/.local/binis the exact directorytrusted_system_bin's docstring names as the hazard. It closes the PATH-ordering half of the hole and leaves the planted-binary half wide open, on the one code path that runs unattended with its output discarded.The cost of the stricter resolver is real and accepted: on a host where kiro-cli lives outside the system directories, this step becomes a logged skip and the operator runs
kiro-cli updatethemselves. That is the same trade_shell_exec_args(which refuses Windows outright) and_trusted_path_env(which returnsNonerather than hand over an inheritedPATH) already make in this update path — an unattended exec does not get to fall back to a lookup an agent can influence. It costs an optional backend refresh, never the gateway's own update.Tests
Three new tests in
TestAutoApplyUpdateKiroCliExecPin, asserting the spawn rather than the source — a source-string check ("does the file mentiontrusted_system_bin") executes none of the new code and would pass on a call that still handscreate_subprocess_execa re-resolvable name:test_spawn_uses_resolved_absolute_path_and_explicit_env—argv[0]is the resolver's absolute path (andos.path.isabs), andenvis the trusted mapping. Asserting only the path would pass on a spawn that still inherits the environment, sinceenv=Nonemeans "inherit".test_step_skipped_when_resolution_returns_none— no spawn at all, and the update continues past it.test_step_skipped_when_no_trusted_env— fails closed with aWARNING, mirroring the wheel path's_trusted_path_env() is Nonerefusal.The resolver stub dispatches on the requested name:
trusted_git_bin()resolves git throughtrusted_system_bin, so a stub that answered every name would silently redirect the seven git spawns too and the tests would stop exercising the path they claim to.Two existing tests moved their "kiro-cli is present" stub off
shutil.which, which no longer gates the step; without that,test_venv_update_full_pathwould have depended on whether the host running it happens to have a kiro-cli installed.Revert-verified (re-run after switching resolvers). With the fix deleted and the pre-fix spawn restored in place, all three fail on the intended assertions —
AssertionError: assert 'kiro-cli' == '/usr/bin/kiro-cli'for the first, and a recorded spawn where none is permitted for the two skip cases — then all three pass with the fix restored.black(baselined gate, scoped toorigin/main...HEAD),isort,flake8,mypy, andscrub-lintall clean. Diff-scoped gates run with base refs exported (BRAND_BASE_REFand equivalents), not bare — bare runs report a false green: brand-name, focus-cue, changelog-history, harness-parity all pass.test_slack_gateway.py,test_spawn_audit.py,test_governance_updates.py,test_update_check_install_aware.py: 527 passed, 2 skipped.Pattern harvest
Rule candidate: semgrep
Pattern:
shutil.which(X)used as the condition of a spawn whoseargv[0]is the bare nameX— the resolved path is discarded andexecvpre-resolves offPATHat spawn time, so the check authorizes an exec it does not pinScope
Deliberately one call site.
_warn_if_kiro_cli_outdatedspawns a bare"kiro-cli", "--version"with the same shape and is not touched here — it is a boot-time probe on a different reachability path, and is reported separately rather than folded in.