fix(terminal): complete subcommands for tools the user installed - #2429
Conversation
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS A dead-on-macOS feature fixed at its root cause (the uid-0-only chain rule), following the precedent The widened trust predicate is proportionate to this project's single-user trust boundary: the residual risk (same-uid agent planting a shadow binary) is honestly argued and bounded by the closed allowlist, the location-based refusal of agent-writable trees, and the strict sandbox — and the rejected alternative (operator trusted-dir opt-in) is documented with its reason. Error paths on the new workspace-root lookup fail closed rather than open, the spec doc is updated in the same commit, and no config surface, schema, or one-way door is introduced. The one thing to know (not a blocker): [DESIGN-REVIEWED] a0b774d |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @dwu96 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: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThis is a well-contained, thoroughly-documented security-hardening change to the terminal command-completion PATH sanitizer. Let me verify the core logic paths for fail-closed behavior. Reviewing the changed logic:
The change loosens the prior "uid 0 only" chain rule to also accept the gateway user's own uid — but this is a deliberate, reasoned relaxation with a compensating location-based guard ( The lazy No credential-path, injection, crash, or fail-open regression on any changed line. No findings. [OPUS-REVIEWED] a0b774d Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
Dispositions for GPT 5.6 — BLOCKING,
|
The command tier resolved a command name only inside a directory chain owned by uid 0, so on macOS it admitted /usr/bin and nothing else. Homebrew installs into a prefix owned by the installing user (/opt/homebrew, group admin), which excluded gh, docker and kubectl -- the tools this tier was built for. Only git completed, and only because it ships in /usr/bin. Trust now accepts a node owned by root OR by the user this gateway runs as, while still refusing anything a third party could write: world-writable always, and group-writable only for an administrator group (macOS admin, gid 80, whose members can already sudo -- no gid qualifies on Linux). A ROOT gateway gets no group-write exemption at all, since its children are root too and a merely-admin account could otherwise substitute a binary that executes with root privileges; for euid 0 the predicate reduces to the rule it replaces. One predicate is applied to every component of the canonical chain and to the resolved binary itself, so a symlink into someone else's tree still fails. Ownership alone cannot see a checkout's own bin/ or scripts/: those are owned by the same user, match no project-local segment name, and a .envrc or dev shell commonly PREPENDS them so they win resolution over the genuine tool. PATH entries and resolved targets inside KIROCREW_PROJECT_DIR or the workspace root are therefore refused by location, mirroring the provider-CLI check in handlers/source_providers.py, which took this same ownership trade. The residual cost, stated in _sanitized_path: the agent shares the gateway's uid, so a directory the filter keeps is one the agent could plant a binary in. _KNOWN is closed, so a plant must shadow a specific real tool name and win PATH order; the plant does not choose the argv; and an agent that can write files already holds more reliable execution paths (~/.zshrc, a git hook, a LaunchAgent) that no PATH filter touches. The module spec in docs/system-specs is updated in the same commit, since it documented the rule this replaces.
bc793a2 to
a0b774d
Compare
|
/ai-review override gpt a0b774d: This applies the ownership policy already on main for provider CLIs ( Human ratification of the boundary shift, as Design Review asked for. The override is not a claim that the finding is factually wrong — the What is already on
|
Human judgment recorded@dwu96 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. |
|
Final disposition — everything raised on this PR is now answered. Design Review — ✅ PASS, one non-blocking note: "
|
…odotdev#2429) The command tier resolved a command name only inside a directory chain owned by uid 0, so on macOS it admitted /usr/bin and nothing else. Homebrew installs into a prefix owned by the installing user (/opt/homebrew, group admin), which excluded gh, docker and kubectl -- the tools this tier was built for. Only git completed, and only because it ships in /usr/bin. Trust now accepts a node owned by root OR by the user this gateway runs as, while still refusing anything a third party could write: world-writable always, and group-writable only for an administrator group (macOS admin, gid 80, whose members can already sudo -- no gid qualifies on Linux). A ROOT gateway gets no group-write exemption at all, since its children are root too and a merely-admin account could otherwise substitute a binary that executes with root privileges; for euid 0 the predicate reduces to the rule it replaces. One predicate is applied to every component of the canonical chain and to the resolved binary itself, so a symlink into someone else's tree still fails. Ownership alone cannot see a checkout's own bin/ or scripts/: those are owned by the same user, match no project-local segment name, and a .envrc or dev shell commonly PREPENDS them so they win resolution over the genuine tool. PATH entries and resolved targets inside KIROCREW_PROJECT_DIR or the workspace root are therefore refused by location, mirroring the provider-CLI check in handlers/source_providers.py, which took this same ownership trade. The residual cost, stated in _sanitized_path: the agent shares the gateway's uid, so a directory the filter keeps is one the agent could plant a binary in. _KNOWN is closed, so a plant must shadow a specific real tool name and win PATH order; the plant does not choose the argv; and an agent that can write files already holds more reliable execution paths (~/.zshrc, a git hook, a LaunchAgent) that no PATH filter touches. The module spec in docs/system-specs is updated in the same commit, since it documented the rule this replaces.
Problem
Terminal subcommand completion works for
gitand for nothing else on macOS. Typingghopens no menu;gitopens a full one. Same fordocker,kubectl, and every other tool in_KNOWN.Why it matters
The command tier exists to answer "which subcommand did you mean" for exactly these CLIs. On macOS — the platform most KiroCrew users are on — it completed only the handful of tools shipped in
/usr/bin, so the feature reads as broken rather than as scoped.gitlooked like it worked; it was the accident of/usr/bin/gitexisting.Fix (symptom → root cause → change)
Symptom:
ghproduces no entries,gitproduces 150.Root cause:
_is_trusted_dirrequired every component of the resolved chain — and_resolverequired the binary itself — to be owned by uid 0. Homebrew installs into a prefix owned by the installing user:/opt/homebrewis uid 503,/opt/homebrew/binisdrwxrwxr-xgroupadmin, and the real binary sits in/opt/homebrew/Cellar/gh/<v>/bin/gh, also user-owned. So_sanitized_pathdropped the entry, and even/usr/local/bin/gh(a root-owned symlink) failed at the target check./usr/bin/gitwas the only allowlisted tool that passed.Change: one predicate,
_trusted_owner, applied to every chain component and to the resolved binary:_ADMIN_WRITE_GIDS= gid 80adminon macOS only, whose members can alreadysudo; no gid qualifies on Linux, whereroot/wheelmembership does not imply sudo rights);st_uid == 0 and not (st_mode & (S_IWGRP | S_IWOTH)).Ownership alone cannot see one important case: a checkout's own
bin/orscripts/. Those are owned by the same user, match no_PROJECT_LOCAL_SEGMENTSname, and a.envrcor dev shell commonly prepends them to PATH — so they would win resolution over the genuine tool. PATH entries and resolved targets insideKIROCREW_PROJECT_DIRorworkspace_root()are therefore refused by location (_under_agent_writable_root), mirroring the provider-CLI check inhandlers/source_providers.py, which already took this same ownership trade for the same reason. That lookup fails closed: if the workspace lookup raises or a root cannot be canonicalized, the root set isNoneand every entry is refused (no completions, one warning) rather than the unresolved root being silently dropped.The trade, stated plainly
The agent shares the gateway's uid, so a directory this filter now keeps is a directory the agent could plant a binary in. Three things bound that:
_KNOWNis a closed allowlist — a plant must shadow a specific real tool name and win PATH order against the genuine install;__complete/--list-cmds);~/.zshrc, a git hook, a LaunchAgent — that no PATH filter touches, so refusing the user's own install buys no real containment.The trees the agent most plausibly writes are refused by location regardless. There is still no operator opt-in to widen this further.
docs/system-specs/modules/learn-cron-dashboard.mdis updated in the same commit, since it documented the rule this replaces sentence by sentence.Tests
test/test_terminal_commands.py, +12 cases (153 pass in the file):test_admits_an_install_owned_by_the_gateways_own_user_resolvereturns a user-owned install (the reported bug)test_ownership_accepts_root_and_this_user_but_no_one_elsetest_ownership_rejects_world_writable_whoever_owns_ittest_ownership_rejects_group_writable_for_an_ordinary_grouptest_ownership_accepts_group_writable_only_for_an_administrator_grouptest_a_root_gateway_gets_no_administrator_group_exemptiontest_ownership_on_a_host_without_uids_keeps_the_administrator_branchNonebehaviour, incl. no exemptiontest_the_whole_chain_must_be_trusted_not_just_the_leaftest_refuses_a_third_partys_file_inside_a_trusted_directorytest_drops_path_entries_inside_the_agent_writable_project<repo>/binon PATH is droppedtest_refuses_a_binary_whose_target_is_inside_the_agent_writable_projecttest_a_sibling_of_a_root_is_not_inside_it…/workspace-otheris outside…/workspacetest_an_undetermined_root_set_refuses_every_entry,test_a_failing_workspace_lookup_yields_no_roots_rather_than_an_empty_set,test_an_uncanonicalizable_root_yields_no_rootsOwnership tests feed fabricated
os.statresults (_stat_chain,_fake_st) rather than real temp paths on purpose: the chain above a temp dir is world-writable/tmpon Linux and a per-user dir on macOS, so asserting against it would test the host instead of the predicate.Manual verification
Reproduced the original diagnosis on this host before changing anything:
/opt/homebrew/bin/gh → /opt/homebrew/Cellar/gh/2.96.0/bin/ghis uid 503 (refused by the old rule at both the directory and the file check),/usr/local/bin/ghis a root-owned symlink whose target is the same user-owned file (refused at the target check),/usr/bin/gitis uid 0 (the one that passed).Screenshots
N/A — no user-visible UI change. The rendered menu is unchanged; only which binaries are eligible to populate it.
Local gates
pytest (full suite, on the pre-rebase commit): 39,730 passed. Three failures, none from this diff — two reproduce on clean
origin/mainon this macOS host (test_the_environment_is_an_allowlist_not_a_filtered_inherit, which sees an OS-injected__CF_USER_TEXT_ENCODING;test_cron_cancel::test_run_command_sandboxed_can_be_cancelled_mid_run), andtest_dev_fleet_app::test_toctou_clean_unmerged_force_omits_git_forcepasses in isolation and as a whole file on this branch (a pre-existing ordering flake under parallel full-suite runs). isort and flake8 clean. mypy reports only three pre-existinghooks.pyxattr errors, which are macOS-typeshed artifacts (os.listxattr/getxattr/setxattrare Linux-only in typeshed) and do not appear on CI's Linux runners; the changed files are clean.