fix(install,router): close 5 HIGH audit findings

1. HIGH-1: onboarding ↔ kei-model-router связка
   До: onboarding мастер писал ~/.claude/config/onboarding.toml,
   но router его не читал — выбор провайдера декоративный.
   После: lib-onboarding.sh::onboarding_write_config доп. пишет
   ~/.claude/config/user-model-override.toml; registry.rs::Registry
   получил load_user_override() возвращающий UserModelOverride.
   Приоритет: --pinned > user-override > agent-profiles default_model_ref.
   2 новых теста (round-trip TOML, optional transport).

2. HIGH-2: eval "$install_cmd" → bash -c "$install_cmd"
   До: lib-preflight.sh::preflight_offer_install делал eval.
   После: bash -c с явным subshell + печать команды юзеру до запуска.

3. HIGH-3: codex.sh regex false-pass
   До: grep -qiE "logged.in|active" пропускал "not logged in" как pass.
   После: сначала negative-pattern (not logged|signed out|please log in),
   потом positive (\blogged in\b|status: active|auth: yes).

4. HIGH-4: path traversal в source preflight
   До: lib-preflight.sh::preflight_run делал source без валидации
   provider id — `../../../evil` сработал бы.
   После: whitelist regex ^[a-z0-9][a-z0-9_-]{0,63}$ + realpath
   проверка что resolved путь не вышел за PREFLIGHT_DIR.

5. HIGH-5: curl|sh без verification
   ollama-local.sh + google-vertex.sh теперь печатают предупреждение
   что Linux-установка тянет shell-скрипт с внешнего сервера без
   проверки хэша/подписи, и предлагают альтернативу.

MEDIUM попутно:
   - anthropic-bedrock.sh: один вызов aws sts get-caller-identity
     вместо двух (экономит 1-3с), различает cred-error от network
     по тексту stderr, маскирует account ID в ARN перед печатью.
   - mlx-local.sh: pip install --user mlx-lm вместо global pip install
     (не требует sudo, не загрязняет system Python).

Тесты: cargo test --lib 80/80, bash -n всех изменённых файлов чисто.
This commit is contained in:
Parfii-bot 2026-05-17 16:28:33 +08:00
parent 0a8c93561f
commit a3ffaed374
8 changed files with 144 additions and 14 deletions

View file

@ -6,11 +6,22 @@
//! Types in `registry_types.rs` (Constructor Pattern: types separate from loader). //! Types in `registry_types.rs` (Constructor Pattern: types separate from loader).
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Deserialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub use crate::registry_types::{Model, Profile, Provider}; pub use crate::registry_types::{Model, Profile, Provider};
use crate::registry_types::{ModelsFile, ProfilesFile, ProvidersFile}; use crate::registry_types::{ModelsFile, ProfilesFile, ProvidersFile};
/// User-tier override: что пользователь выбрал в onboarding мастере.
/// Парсится из `~/.claude/config/user-model-override.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct UserModelOverride {
pub provider: String,
pub model: String,
#[serde(default)]
pub transport: Option<String>,
}
// Embedded compile-time copies. Cargo tracks these as implicit dependencies: // Embedded compile-time copies. Cargo tracks these as implicit dependencies:
// if the TOML changes, the crate is recompiled automatically. // if the TOML changes, the crate is recompiled automatically.
const EMBEDDED_PROVIDERS: &str = const EMBEDDED_PROVIDERS: &str =
@ -65,6 +76,25 @@ impl Registry {
}) })
} }
/// Загружает user-tier override из `~/.claude/config/user-model-override.toml`.
/// Файл пишется установщиком (install/lib-onboarding.sh::onboarding_write_config)
/// при первичной настройке. Содержит выбор юзера: provider/model/transport.
///
/// Priority в router: `--pinned` flag > этот файл > agent-profiles.toml::default_model_ref.
/// Без него выбор провайдера в onboarding декоративен (HIGH аудит-1, 2026-05-17).
pub fn load_user_override() -> Option<UserModelOverride> {
let home = std::env::var("HOME").ok()?;
if home.is_empty() {
return None;
}
let path = PathBuf::from(format!("{home}/.claude/config/user-model-override.toml"));
if !path.exists() {
return None;
}
let raw = std::fs::read_to_string(&path).ok()?;
toml::from_str::<UserModelOverride>(&raw).ok()
}
pub fn provider_by_id(&self, id: &str) -> Option<&Provider> { pub fn provider_by_id(&self, id: &str) -> Option<&Provider> {
self.providers.iter().find(|p| p.id == id) self.providers.iter().find(|p| p.id == id)
} }
@ -137,7 +167,38 @@ mod tests {
fn provider_by_id_anthropic() { fn provider_by_id_anthropic() {
let r = reg(); let r = reg();
let p = r.provider_by_id("anthropic").expect("anthropic missing"); let p = r.provider_by_id("anthropic").expect("anthropic missing");
assert_eq!(p.display_name, "Anthropic"); // После RULE 0.26 transport-расширения display_name стало
// "Anthropic (Direct API)" чтобы отличать от "anthropic-bedrock".
assert!(
p.display_name.starts_with("Anthropic"),
"got: {}", p.display_name
);
}
#[test]
fn user_override_parses_minimal_toml() {
// Round-trip: TOML → UserModelOverride.
let toml_src = r#"
provider = "ollama-local"
model = "llama-3.3-70b"
transport = "local"
"#;
let ov: UserModelOverride = toml::from_str(toml_src).expect("parse failed");
assert_eq!(ov.provider, "ollama-local");
assert_eq!(ov.model, "llama-3.3-70b");
assert_eq!(ov.transport.as_deref(), Some("local"));
}
#[test]
fn user_override_transport_optional() {
// transport — optional поле.
let toml_src = r#"
provider = "openai"
model = "gpt-5"
"#;
let ov: UserModelOverride = toml::from_str(toml_src).expect("parse failed");
assert_eq!(ov.provider, "openai");
assert!(ov.transport.is_none());
} }
#[test] #[test]

View file

@ -300,6 +300,21 @@ transport = "$ONBOARDING_TRANSPORT"
provider = "$ONBOARDING_PROVIDER" provider = "$ONBOARDING_PROVIDER"
default_model = "$ONBOARDING_MODEL" default_model = "$ONBOARDING_MODEL"
EOF EOF
# Дополнительный файл специально для kei-model-router.
# Имеет приоритет выше agent-profiles.toml default_model_ref,
# ниже --pinned flag в коде. Router читает его как user-tier override.
# Без него выбор провайдера в onboarding декоративен (HIGH аудит-1).
local override_path="$HOME/.claude/config/user-model-override.toml"
cat > "$override_path" <<EOF
# User-tier model override. Auto-generated by onboarding wizard.
# Format: kei-model-router::Registry::load_user_override().
# Priority: --pinned flag > этот файл > agent-profiles.toml default_model_ref.
provider = "$ONBOARDING_PROVIDER"
model = "$ONBOARDING_MODEL"
transport = "$ONBOARDING_TRANSPORT"
EOF
: > "$ONBOARDED_FLAG" : > "$ONBOARDED_FLAG"
} }

View file

@ -26,10 +26,13 @@ preflight_offer_install() {
echo " Установить: $install_cmd" >&2 echo " Установить: $install_cmd" >&2
echo "" >&2 echo "" >&2
if [ -t 0 ] && [ -t 1 ]; then if [ -t 0 ] && [ -t 1 ]; then
echo " ⓘ команда: $install_cmd" >&2
read -r -p " Поставить сейчас? [y/N/skip] " ans read -r -p " Поставить сейчас? [y/N/skip] " ans
case "$ans" in case "$ans" in
y|Y|yes) y|Y|yes)
eval "$install_cmd" # bash -c вместо eval — explicit subshell, не word-splitting'тся
# лишний раз в текущем процессе.
bash -c "$install_cmd"
return $? return $?
;; ;;
skip|s|S) skip|s|S)
@ -51,8 +54,17 @@ preflight_offer_install() {
preflight_run() { preflight_run() {
local provider="$1" local provider="$1"
[ -z "$provider" ] && return 0 [ -z "$provider" ] && return 0
# Whitelist символов в provider-id: только [a-z0-9_-], длина 1..64.
# Защищает от path-traversal (../) и shell-инъекций через имя файла.
if ! [[ "$provider" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ ]]; then
echo " ⚠ preflight: provider id '$provider' содержит недопустимые символы — пропуск" >&2
return 0
fi
local script="$PREFLIGHT_DIR/${provider}.sh" local script="$PREFLIGHT_DIR/${provider}.sh"
if [ ! -f "$script" ]; then # Проверяем что resolved путь не вышел за PREFLIGHT_DIR (на случай symlink'ов).
local resolved
resolved="$(cd "$PREFLIGHT_DIR" 2>/dev/null && pwd -P)/${provider}.sh"
if [ ! -f "$script" ] || [ ! -f "$resolved" ]; then
return 0 # CLI не нужен — direct-api, ключ собирается ниже return 0 # CLI не нужен — direct-api, ключ собирается ниже
fi fi
# shellcheck disable=SC1090 # shellcheck disable=SC1090

View file

@ -11,16 +11,30 @@ preflight_check_anthropic_bedrock() {
esac esac
preflight_offer_install "aws CLI" "$cmd" || return 1 preflight_offer_install "aws CLI" "$cmd" || return 1
fi fi
# Проверяем что credentials хоть как-то настроены (env, ~/.aws/credentials, IAM role). # Один вызов вместо двух — экономит ~1-3с при success-path.
if ! aws sts get-caller-identity >/dev/null 2>&1; then local identity_out identity_rc
identity_out="$(aws sts get-caller-identity --output json 2>&1)"
identity_rc=$?
if [ $identity_rc -ne 0 ]; then
echo "" >&2 echo "" >&2
echo " ⚠ AWS credentials не настроены." >&2 # Различаем cred-ошибку от сетевой/прочей по тексту.
if echo "$identity_out" | grep -qiE "UnauthorizedAccess|InvalidClientTokenId|ExpiredToken|signature|credential"; then
echo " ⚠ AWS credentials невалидны." >&2
echo " Запустите: aws configure" >&2 echo " Запустите: aws configure" >&2
echo " Или экспортируйте AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_REGION." >&2 echo " Или экспортируйте AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_REGION." >&2
else
echo " ⚠ aws sts get-caller-identity упал (не credentials)." >&2
echo " raw: $identity_out" >&2
fi
echo "" >&2 echo "" >&2
return 1 return 1
fi fi
echo " ✓ aws CLI: $(aws --version 2>&1 | head -1)" >&2 echo " ✓ aws CLI: $(aws --version 2>&1 | head -1)" >&2
echo " ✓ identity: $(aws sts get-caller-identity --query Arn --output text 2>&1)" >&2 # ARN не печатаем полностью — может содержать account-id (sensitive enum target).
# Показываем только тип identity (user/role/assumed-role) и user-name.
local arn_short
arn_short="$(echo "$identity_out" | sed -n 's/.*"Arn": *"\(arn:aws:[^:]*::\)[0-9]*\(:[^"]*\)".*/\1***\2/p')"
[ -z "$arn_short" ] && arn_short="<masked>"
echo " ✓ identity: $arn_short" >&2
return 0 return 0
} }

View file

@ -13,9 +13,13 @@ preflight_check_codex() {
preflight_offer_install "codex CLI" "npm install -g @openai/codex" || return 1 preflight_offer_install "codex CLI" "npm install -g @openai/codex" || return 1
fi fi
# Проверяем что OAuth активен. # Проверяем что OAuth активен.
# Regex'ы: позитивные паттерны (logged-in / signed-in / active) И
# негативные (not logged in / logged out / sign in required) — иначе
# фраза "you are not logged in" проходит через `logged.in` regex
# как false-pass.
local status local status
status="$(codex login status 2>&1 || true)" status="$(codex login status 2>&1 || true)"
if ! echo "$status" | grep -qiE "logged.in|active"; then if echo "$status" | grep -qiE 'not (logged|signed) (in|on)|logged out|signed out|please.*log\s*in|sign[[:space:]]*in[[:space:]]*required'; then
echo "" >&2 echo "" >&2
echo " ⚠ codex не залогинен в ChatGPT." >&2 echo " ⚠ codex не залогинен в ChatGPT." >&2
echo " Запустите: codex login" >&2 echo " Запустите: codex login" >&2
@ -23,7 +27,15 @@ preflight_check_codex() {
echo "" >&2 echo "" >&2
return 1 return 1
fi fi
if ! echo "$status" | grep -qiE '\b(logged|signed) in\b|status:[[:space:]]*active|auth(enticated)?[[:space:]]*:[[:space:]]*(yes|true|ok)'; then
echo "" >&2
echo " ⚠ codex auth-статус неопределён:" >&2
echo " $status" >&2
echo " Запустите: codex login" >&2
echo "" >&2
return 1
fi
echo " ✓ codex CLI: $(codex --version 2>&1 | head -1)" >&2 echo " ✓ codex CLI: $(codex --version 2>&1 | head -1)" >&2
echo " ✓ OAuth: $status" >&2 echo " ✓ OAuth: active" >&2
return 0 return 0
} }

View file

@ -6,7 +6,13 @@ preflight_check_google_vertex() {
local cmd local cmd
case "$(uname -s)" in case "$(uname -s)" in
Darwin) cmd="brew install --cask google-cloud-sdk" ;; Darwin) cmd="brew install --cask google-cloud-sdk" ;;
Linux) cmd="curl https://sdk.cloud.google.com | bash" ;; Linux)
echo " ⚠ Linux установка gcloud тянет скрипт с sdk.cloud.google.com" >&2
echo " и выполняет его как bash — без проверки хэша." >&2
echo " Альтернатива: пакет из репов apt/dnf, либо ручная установка по" >&2
echo " https://cloud.google.com/sdk/docs/install#linux" >&2
cmd="curl https://sdk.cloud.google.com | bash"
;;
*) cmd="см. https://cloud.google.com/sdk/docs/install" ;; *) cmd="см. https://cloud.google.com/sdk/docs/install" ;;
esac esac
preflight_offer_install "gcloud CLI" "$cmd" || return 1 preflight_offer_install "gcloud CLI" "$cmd" || return 1

View file

@ -9,7 +9,8 @@ preflight_check_mlx_local() {
return 1 return 1
fi fi
if ! command -v mlx_lm.server >/dev/null 2>&1; then if ! command -v mlx_lm.server >/dev/null 2>&1; then
preflight_offer_install "mlx_lm" "pip install mlx-lm" || return 1 # --user избегает sudo и не загрязняет system Python.
preflight_offer_install "mlx_lm" "pip install --user mlx-lm" || return 1
fi fi
if ! curl -fsS --max-time 3 http://127.0.0.1:8080/v1/models >/dev/null 2>&1; then if ! curl -fsS --max-time 3 http://127.0.0.1:8080/v1/models >/dev/null 2>&1; then
echo "" >&2 echo "" >&2

View file

@ -6,7 +6,16 @@ preflight_check_ollama_local() {
local cmd local cmd
case "$(uname -s)" in case "$(uname -s)" in
Darwin) cmd="brew install ollama" ;; Darwin) cmd="brew install ollama" ;;
Linux) cmd="curl -fsSL https://ollama.com/install.sh | sh" ;; Linux)
# WARNING: curl|sh без verification — supply-chain риск.
# Если есть apt/dnf — лучше через них, но ollama не в репах
# большинства дистров. Предупреждаем юзера явно.
echo " ⚠ Linux установка ollama тянет скрипт с https://ollama.com и" >&2
echo " выполняет его как shell — без проверки хэша/подписи." >&2
echo " Альтернатива: скачать вручную с https://ollama.com/download/linux" >&2
echo " и проверить SHA256 перед запуском." >&2
cmd="curl -fsSL https://ollama.com/install.sh | sh"
;;
*) cmd="см. https://ollama.com/download" ;; *) cmd="см. https://ollama.com/download" ;;
esac esac
preflight_offer_install "ollama" "$cmd" || return 1 preflight_offer_install "ollama" "$cmd" || return 1