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:
parent
0a8c93561f
commit
a3ffaed374
8 changed files with 144 additions and 14 deletions
|
|
@ -6,11 +6,22 @@
|
|||
//! Types in `registry_types.rs` (Constructor Pattern: types separate from loader).
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use crate::registry_types::{Model, Profile, Provider};
|
||||
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:
|
||||
// if the TOML changes, the crate is recompiled automatically.
|
||||
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> {
|
||||
self.providers.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
|
@ -137,7 +167,38 @@ mod tests {
|
|||
fn provider_by_id_anthropic() {
|
||||
let r = reg();
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -300,6 +300,21 @@ transport = "$ONBOARDING_TRANSPORT"
|
|||
provider = "$ONBOARDING_PROVIDER"
|
||||
default_model = "$ONBOARDING_MODEL"
|
||||
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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ preflight_offer_install() {
|
|||
echo " Установить: $install_cmd" >&2
|
||||
echo "" >&2
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
echo " ⓘ команда: $install_cmd" >&2
|
||||
read -r -p " Поставить сейчас? [y/N/skip] " ans
|
||||
case "$ans" in
|
||||
y|Y|yes)
|
||||
eval "$install_cmd"
|
||||
# bash -c вместо eval — explicit subshell, не word-splitting'тся
|
||||
# лишний раз в текущем процессе.
|
||||
bash -c "$install_cmd"
|
||||
return $?
|
||||
;;
|
||||
skip|s|S)
|
||||
|
|
@ -51,8 +54,17 @@ preflight_offer_install() {
|
|||
preflight_run() {
|
||||
local provider="$1"
|
||||
[ -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"
|
||||
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, ключ собирается ниже
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
|
|
|
|||
|
|
@ -11,16 +11,30 @@ preflight_check_anthropic_bedrock() {
|
|||
esac
|
||||
preflight_offer_install "aws CLI" "$cmd" || return 1
|
||||
fi
|
||||
# Проверяем что credentials хоть как-то настроены (env, ~/.aws/credentials, IAM role).
|
||||
if ! aws sts get-caller-identity >/dev/null 2>&1; then
|
||||
# Один вызов вместо двух — экономит ~1-3с при success-path.
|
||||
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 " ⚠ AWS credentials не настроены." >&2
|
||||
echo " Запустите: aws configure" >&2
|
||||
echo " Или экспортируйте AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_REGION." >&2
|
||||
# Различаем cred-ошибку от сетевой/прочей по тексту.
|
||||
if echo "$identity_out" | grep -qiE "UnauthorizedAccess|InvalidClientTokenId|ExpiredToken|signature|credential"; then
|
||||
echo " ⚠ AWS credentials невалидны." >&2
|
||||
echo " Запустите: aws configure" >&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
|
||||
return 1
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ preflight_check_codex() {
|
|||
preflight_offer_install "codex CLI" "npm install -g @openai/codex" || return 1
|
||||
fi
|
||||
# Проверяем что 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
|
||||
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 " ⚠ codex не залогинен в ChatGPT." >&2
|
||||
echo " Запустите: codex login" >&2
|
||||
|
|
@ -23,7 +27,15 @@ preflight_check_codex() {
|
|||
echo "" >&2
|
||||
return 1
|
||||
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 " ✓ OAuth: $status" >&2
|
||||
echo " ✓ OAuth: active" >&2
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ preflight_check_google_vertex() {
|
|||
local cmd
|
||||
case "$(uname -s)" in
|
||||
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" ;;
|
||||
esac
|
||||
preflight_offer_install "gcloud CLI" "$cmd" || return 1
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ preflight_check_mlx_local() {
|
|||
return 1
|
||||
fi
|
||||
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
|
||||
if ! curl -fsS --max-time 3 http://127.0.0.1:8080/v1/models >/dev/null 2>&1; then
|
||||
echo "" >&2
|
||||
|
|
|
|||
|
|
@ -6,7 +6,16 @@ preflight_check_ollama_local() {
|
|||
local cmd
|
||||
case "$(uname -s)" in
|
||||
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" ;;
|
||||
esac
|
||||
preflight_offer_install "ollama" "$cmd" || return 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue