fix(gateway): pin both unattended kiro-cli spawns off PATH - #8396
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound pin at the right seam, but the pinned set is itself same-uid-writable, so this narrows the vector the description claims to close. Watch
[DESIGN-REVIEWED] 408641b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've verified the code. The candidate's evidence matches the diff and the code exists as described. Assessing CANDIDATE 1 against the survival bar:
It dies under falsification. Step 2: the change is a focused, well-guarded security hardening. Only the PATH-excluded No findings. [OPUS-REVIEWED] 408641b 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) — ✅ PASSPremise-level review of All claims verified: siblings at First-Principles-Verdict: PASS Both unattended spawns now exec a path resolved in-process from operator-controlled directories; every rider is declared, counted, and traced to the PR #5387 finding. What this change shipsIntent: stop the gateway's two unattended kiro-cli spawns from letting an agent-writable
Counts run: WatchRoot-cause boundary is drawn at this file, not at "unattended": session spawns resolve in-process ( [FIRST-PRINCIPLES-REVIEWED] 408641b |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @iamwhatever overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
22aeb7a to
cb2d1c0
Compare
cb2d1c0 to
24deff5
Compare
`_auto_apply_update` and `_warn_if_kiro_cli_outdated` both exec'd the bare
name `"kiro-cli"`, so the lookup that decides which program runs happened
inside `exec`, against the gateway's inherited `PATH`. That `PATH` can lead
with an agent-writable directory (a worktree venv's `bin`) — anything able
to drop an executable of that name there gets code execution as the gateway
user. Both paths are unattended: one is auto-update, the other runs at boot.
In the auto-update path `shutil.which("kiro-cli")` was the guard, but its
answer was thrown away, so the probe and the spawn could resolve to
different files.
Both now go through `_pinned_kiro_cli`, which returns an absolute path or
refuses. The candidate set is the fixed known install directories plus the
operator's `KIROCREW_KIRO_BIN`, with the inherited `PATH` excluded, so no
directory an agent can plant a file in gets to name what these paths run.
`None` fail-closes to skipping the step, matching what the git spawns beside
them do when `trusted_git_bin()` returns `None`.
`trusted_system_bin` is wrong for this binary specifically: kiro-cli is a
user-installed backend that lives in `~/.local/bin`, never in the system
directories, so that pin would return `None` on every real install and
retire both steps.
The pin is bounded and off the loop. `_warn_if_kiro_cli_outdated` is awaited
by `_init_services` strictly before `_init_dashboard` binds its socket, and
the lookup stats directories under the home directory — unbounded, an
unresponsive network-mounted home would keep the gateway from ever coming up.
Overrunning the budget refuses the spawn like an absent binary does.
Refusing quietly would have been its own defect: a host whose only kiro-cli
is findable through `PATH` keeps launching sessions with it while never
auto-updating and never warning it is outdated. The pin therefore
distinguishes "declined an install that exists" — logged, naming
`KIROCREW_KIRO_BIN` as the way to have it used — from "no backend
installed", which is not worth a line.
`resolve_kiro_cli` grows an `include_inherited_path` keyword forwarded to
`find_kiro_cli_candidates`, which already had it. Default `True`, so the
three existing consumers are unchanged.
Tests: the resolved absolute path reaches `create_subprocess_exec` on both
paths, the pin excludes the inherited `PATH` on both, an unresolvable
kiro-cli spawns nothing while the rest of the update still runs, a
PATH-only install is refused AND reported, an absent backend is refused
quietly, and a wedged lookup is bounded rather than stalling boot. All
revert-verified: removing the bound, removing the warning, or widening it to
fire when no backend exists each fails a test.
24deff5 to
408641b
Compare
|
/ai-review override gpt 408641bc9edd06f1b0e0e8e2b78a12df8fca5c9d: kiro-cli's documented install directory is where install.sh also symlinks the gateway's own |
|
AI-review override not recorded: |
|
[operator: iamwhatever] Dispositions for the round on GPT F2 — new awaited work before the dashboard binds — GPT F3 — docstring contradicted the retained GPT F1 — unattended spawns still trust agent-writable install directories — Design Review — silent refusal leaves an undiagnosable drift state — Design Review + First Principles — First Principles — the chosen set retires a root-owned |
|
/ai-review override gpt 408641b: kiro-cli's documented install directory is where install.sh also symlinks the gateway's own launcher, so an agent that can plant a binary there can already replace the entry point the gateway runs from, and the requested agent-writability rejection would refuse every standard install while buying nothing. [operator: iamwhatever] Re-posting this override: the earlier one named a SHA I got wrong ( |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem / Motivation
Two spawns in
slack/gateway.pyexec'd the bare name"kiro-cli", so the lookup that decides which program runs happened insideexec, against the gateway's inheritedPATH:_auto_apply_update— the unattended auto-update, also what an enterprisemin_versionfloor calls._warn_if_kiro_cli_outdated— the version probe at gateway boot.The auto-update's guard was
shutil.which("kiro-cli"), whose answer was discarded — it proved a binary existed somewhere onPATHand then letexecresolve again, so probe and spawn could land on different files. The boot probe had no guard at all.Why it matters
HIGH. A gateway's
PATHcan legitimately lead with an agent-writable directory —platform_compat.trusted_system_bin's own docstring names a worktree venv'sbin. Anything able to drop an executable calledkiro-clithere gets code execution as the gateway user on a path with no operator present. The--versionargument is no protection on the boot probe: a planted shim executes before it ever reads its argv.The git spawns in
_auto_apply_updatewere already hardened for exactly this reason —trusted_git_bin()for the binary,git_command_env()for the environment. These two kiro-cli spawns were what was left.What changed (motivation → approach → change)
Bare argv0 re-resolved inside
exec→ resolution must happen in-process, from a candidate set no agent-writable directory contributes to, and the resolved path must be what runs → both sites call_pinned_kiro_cli, which returns an absolute path or refuses.The candidate set is the fixed known install directories plus the explicit
KIROCREW_KIRO_BIN, with the inheritedPATHexcluded via a newinclude_inherited_pathkeyword onresolve_kiro_cli— forwarded tofind_kiro_cli_candidates, which already had it, defaultTrue, so the three existing consumers (acp/client.py,cli_doctor.py,review_pool.py) are untouched. The override stays honoured on purpose: it is set by the operator who starts the gateway, not named by a directory an agent can write into.resolve_kiro_clirather thantrusted_system_bin. The system-tool pin restricts to/usr/bin,/bin,/usr/sbin,/sbin,/run/current-system/sw/bin. kiro-cli is a user-installed backend living in~/.local/binor~/.cargo/bin(kiro_cli.known_kiro_cli_dirs) and is never in those, so that pin would returnNoneon every real install and retire both steps.Bounded and off the loop.
_init_servicesawaits_warn_if_kiro_cli_outdatedstrictly before_init_dashboardbinds its socket, and the lookup stats directories under the home directory. Unbounded, an unresponsive network-mounted home would mean the dashboard never comes up; the budget refuses the spawn instead, like an absent binary.Refusal is reported, not silent. The chosen set does not cover every install — a system-wide one outside
known_kiro_cli_dirs, a root-owned/usr/local/binon Linux, is declined here while sessions keep launching it offPATH. Refusing is right for a spawn with no operator present; being quiet about it is not, because that host never auto-updates and never warns it is outdated with nothing in the log to say why. So the pin distinguishes declined an install that exists — logged, namingKIROCREW_KIRO_BIN— from no backend installed, which is not worth a line.No
env=pin.git_command_env()neutralizes git-specific exec vectors (GIT_CONFIG_COUNT/KEY/VALUE) and stripsGIT_DIR/GIT_WORK_TREE, which must be absent rather than overwritten. kiro-cli has no analogous fixed-key exec vector and there is no general non-git system-command env helper, so an environment pin here would be a new layer rather than a mirror of the existing one.Two bare-name kiro-cli spawns remain outside this file:
cli_server.py:1371-1375(thekirocrew updateCLI) anddiagnostics.py:288(_kiro_cli_version, reached fromPOST /api/diagnostics/collect). Both are the same shape and neither is fixed here; tracked in #8495 rather than declared harmless.Tests
test/test_slack_gateway.py— auto-update path:test_kiro_cli_update_execs_resolved_absolute_path— the pinned absolute path is argv0.test_kiro_cli_update_resolves_without_inherited_path— the pin excludes the inheritedPATH.test_kiro_cli_update_skipped_when_unresolvable— a refusal spawns nothing; the frontend build and dependency install still run.test_kiro_cli_update_timeout_kills_child_and_stays_nonfatal— updated to the new guard; tree-kill and non-fatal assertions unchanged.test/test_slack_gateway_more_coverage.py— boot probe:test_probe_execs_resolved_absolute_path— pinned absolute path as argv0,PATHexcluded.test_unresolvable_binary_never_spawns— nothing spawned, nothing warned.test_path_only_install_is_refused_but_reported— refused for the spawn, and the log namesKIROCREW_KIRO_BIN.test_absent_backend_is_refused_quietly— no line for a backend that simply is not installed.test_slow_home_directory_cannot_stall_boot— a wedged lookup is bounded and boot moves on.Revert-verified load-bearing: removing the timeout bound, removing the PATH-only warning, or widening that warning to fire when no backend exists each fails a test; dropping
include_inherited_path=Falseand restoring the bare argv0 each fail two more. 1539 tests pass across the gateway, more-coverage, spawn-audit, governance-update, kiro-prerequisite, ACP-client, doctor and env suites.Manual verification
N/A — every branch is argv0, guard shape, or a log line at a spawn site, all observable in unit coverage.
Related Issues
Security finding from merged PR #5387 (untrusted PATH resolution → arbitrary code execution).
Pattern harvest
Rule candidate: semgrep
Pattern:
shutil.which(X)used as a boolean existence test whileXis then passed as a bare argv0 — the resolved path is discarded, the lookup moves intoexec, and probe and spawn can disagree, so an attacker-controlledPATHentry decides the payload.Checklist
_pinned_kiro_cliand the resolver's docstring