The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
478 lines
18 KiB
Rust
478 lines
18 KiB
Rust
//! Requirements layers and fail-closed enforcement.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::env_bool;
|
|
use crate::loader::{apply_version_overrides_with_registered, load_toml_file};
|
|
use crate::paths::{system_config_dir, user_kigi_home};
|
|
use crate::version_overrides::{VersionOverrideError, apply_version_overrides};
|
|
|
|
/// The `requirements.toml` opt-in key for strict (fail-closed) enforcement.
|
|
/// Lives next to the signed payload that carries the flag
|
|
/// ([`crate::signed_policy::SignedPayload::fail_closed`]) so the two sides
|
|
/// can't drift.
|
|
pub const FAIL_CLOSED_KEY: &str = "fail_closed";
|
|
|
|
/// Read the `fail_closed` opt-in from a requirements-TOML string — THE canonical
|
|
/// parse shared by every caller so the semantics can't drift.
|
|
/// Invalid TOML or a non-bool value → `false`.
|
|
pub fn fail_closed_flag_from_str(requirements: &str) -> bool {
|
|
toml::from_str::<toml::Value>(requirements)
|
|
.ok()
|
|
.and_then(|v| v.get(FAIL_CLOSED_KEY).and_then(toml::Value::as_bool))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as
|
|
/// [`fail_closed_flag_from_str`]. Env tightening (file vs `KIGI_MANAGED_CONFIG_FAIL_CLOSED`)
|
|
/// is layered on top by [`resolve_fail_closed_mode`], not here.
|
|
fn fail_closed_flag(requirements: &toml::Value) -> bool {
|
|
requirements
|
|
.get(FAIL_CLOSED_KEY)
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Env override for [`FAIL_CLOSED_KEY`]. Named for prefix-alignment
|
|
/// with `KIGI_MANAGED_CONFIG_URL`; only applies to `requirements.toml`.
|
|
pub(crate) const FAIL_CLOSED_ENV: &str = "KIGI_MANAGED_CONFIG_FAIL_CLOSED";
|
|
|
|
/// Where a requirements layer came from: a file on disk, or the macOS MDM
|
|
/// managed-preferences layer (admin-forced, no file). The typed split keeps a
|
|
/// caller from `exists()`/reading a layer that has no path.
|
|
#[derive(Debug, Clone)]
|
|
pub enum RequirementsSource {
|
|
File(PathBuf),
|
|
Mdm,
|
|
}
|
|
|
|
impl RequirementsSource {
|
|
/// Display/provenance label — a file path string, or the synthetic MDM source
|
|
/// id (`ai.x.kigi:…`). For diagnostics and matching only; the MDM layer has no
|
|
/// file, so this is a label (`Cow<str>`), never a `Path` to open.
|
|
pub fn label(&self) -> std::borrow::Cow<'_, str> {
|
|
match self {
|
|
Self::File(p) => p.to_string_lossy(),
|
|
Self::Mdm => std::borrow::Cow::Borrowed(crate::macos_managed::MDM_REQUIREMENTS_SOURCE),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One requirements layer: the parsed TOML and where it came from.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RequirementsLayer {
|
|
pub value: toml::Value,
|
|
pub source: RequirementsSource,
|
|
/// `true` = root-owned system layer. Security decisions must trust this flag,
|
|
/// not re-derive from the source (`KIGI_SHARE_DIR`-influenced, could carry `..`).
|
|
pub is_system: bool,
|
|
}
|
|
|
|
/// All loaded requirements layers in apply order (user first, system last).
|
|
/// Use when you need per-layer source attribution; otherwise use
|
|
/// [`load_merged_requirements`].
|
|
pub fn requirements_layers() -> Vec<RequirementsLayer> {
|
|
let mut out = Vec::new();
|
|
if let Some(user_path) = user_kigi_home().map(|g| g.join("requirements.toml"))
|
|
&& let Some(value) = load_requirements_layer(&user_path)
|
|
{
|
|
out.push(RequirementsLayer {
|
|
value,
|
|
source: RequirementsSource::File(user_path),
|
|
is_system: false,
|
|
});
|
|
}
|
|
if let Some(dir) = system_config_dir() {
|
|
let sys_path = dir.join("requirements.toml");
|
|
if let Some(value) = load_requirements_layer(&sys_path) {
|
|
out.push(RequirementsLayer {
|
|
value,
|
|
source: RequirementsSource::File(sys_path),
|
|
is_system: true,
|
|
});
|
|
}
|
|
}
|
|
// macOS MDM: OS-protected admin layer (forced values only). Pushed last so it
|
|
// wins the deep-merge over the system file and cloud cache; `is_system` so
|
|
// security decisions trust it like the root-owned layer.
|
|
if let Some(value) = mdm_requirements_value() {
|
|
out.push(RequirementsLayer {
|
|
value,
|
|
source: RequirementsSource::Mdm,
|
|
is_system: true,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// User + system requirements deep-merged, system wins on conflict.
|
|
/// Use for read-only consumers so user pins can't bypass system policy.
|
|
pub fn load_merged_requirements() -> Option<toml::Value> {
|
|
let mut iter = requirements_layers().into_iter();
|
|
let mut merged = iter.next()?.value;
|
|
for layer in iter {
|
|
crate::loader::deep_merge_toml(&mut merged, &layer.value);
|
|
}
|
|
Some(merged)
|
|
}
|
|
|
|
pub(crate) fn load_requirements() -> Option<toml::Value> {
|
|
load_user_requirements(user_kigi_home().as_deref())
|
|
}
|
|
|
|
/// User requirements layer from `<home>/requirements.toml`, or `None` with no
|
|
/// resolvable user home (rather than reading a cwd-relative `.kigi`).
|
|
fn load_user_requirements(home: Option<&Path>) -> Option<toml::Value> {
|
|
load_requirements_layer(&home?.join("requirements.toml"))
|
|
}
|
|
|
|
pub(crate) fn load_system_requirements() -> Option<toml::Value> {
|
|
let dir = system_config_dir()?;
|
|
load_requirements_layer(&dir.join("requirements.toml"))
|
|
}
|
|
|
|
/// Soft-fails on errors; fail-closed enforcement lives in
|
|
/// [`validate_requirements`].
|
|
pub(crate) fn load_requirements_layer(path: &Path) -> Option<toml::Value> {
|
|
let v = match load_toml_file(path) {
|
|
Ok(v) if v.as_table().is_some_and(|t| !t.is_empty()) => v,
|
|
_ => return None,
|
|
};
|
|
normalize_requirements_value(v, &path.display().to_string())
|
|
}
|
|
|
|
/// Strip `fail_closed` and apply `[[version_overrides]]` for a parsed
|
|
/// requirements layer (file or MDM), so every source is normalized identically.
|
|
/// `None` (skip the layer) when version_overrides are invalid for this build.
|
|
pub(crate) fn normalize_requirements_value(
|
|
mut v: toml::Value,
|
|
source: &str,
|
|
) -> Option<toml::Value> {
|
|
if let Some(table) = v.as_table_mut() {
|
|
table.remove(FAIL_CLOSED_KEY);
|
|
}
|
|
if let Err(e) = apply_version_overrides_with_registered(&mut v) {
|
|
tracing::error!(
|
|
source = %source,
|
|
error = %e,
|
|
"requirements rejected: invalid version_overrides; admin policy NOT applied"
|
|
);
|
|
return None;
|
|
}
|
|
Some(v)
|
|
}
|
|
|
|
/// The MDM requirements layer (read + normalized), or `None`. Shared so the
|
|
/// enforced view and the effective-config view agree.
|
|
pub(crate) fn mdm_requirements_value() -> Option<toml::Value> {
|
|
normalize_requirements_value(
|
|
crate::macos_managed::managed_preferences_requirements()?,
|
|
crate::macos_managed::MDM_REQUIREMENTS_SOURCE,
|
|
)
|
|
}
|
|
|
|
/// Errors from validating requirements layers at startup.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RequirementsError {
|
|
#[error(
|
|
"requirements at {} has invalid version_overrides under fail_closed: {source}",
|
|
path.display()
|
|
)]
|
|
InvalidVersionOverrides {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: VersionOverrideError,
|
|
},
|
|
}
|
|
|
|
/// `Ok(())` unless the layer opts into fail_closed AND has invalid
|
|
/// `[[version_overrides]]` for the registered CLI version.
|
|
///
|
|
/// Re-reads the file independently from [`load_requirements_layer`]:
|
|
/// at startup both run, costing one extra small read per layer. Sharing
|
|
/// the parse would couple loader+validator APIs for negligible gain.
|
|
pub(crate) fn validate_requirements_layer(path: &Path) -> Result<(), RequirementsError> {
|
|
let Ok(v) = load_toml_file(path) else {
|
|
return Ok(());
|
|
};
|
|
validate_requirements_value(v, &RequirementsSource::File(path.to_path_buf()))
|
|
}
|
|
|
|
/// Fail-closed `[[version_overrides]]` validation for a parsed requirements layer
|
|
/// (file or MDM). Reads `fail_closed` before applying overrides so a broken patch
|
|
/// can't disable enforcement mid-load. `source` is the provenance label in the error.
|
|
fn validate_requirements_value(
|
|
mut v: toml::Value,
|
|
source: &RequirementsSource,
|
|
) -> Result<(), RequirementsError> {
|
|
if v.as_table().is_none_or(|t| t.is_empty()) {
|
|
return Ok(());
|
|
}
|
|
let fail_closed = resolve_fail_closed_mode(&v);
|
|
let Ok(version) = kigi_version::installed_semver() else {
|
|
return Ok(());
|
|
};
|
|
if let Err(e) = apply_version_overrides(&mut v, &version)
|
|
&& fail_closed
|
|
{
|
|
return Err(RequirementsError::InvalidVersionOverrides {
|
|
path: PathBuf::from(source.label().as_ref()),
|
|
source: e,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates all requirements layers (user + system files, and macOS MDM). Call
|
|
/// once at startup from the binary's `main()`; exit on `Err`.
|
|
pub fn validate_requirements() -> Result<(), RequirementsError> {
|
|
validate_user_requirements(user_kigi_home().as_deref())?;
|
|
if let Some(dir) = system_config_dir() {
|
|
validate_requirements_layer(&dir.join("requirements.toml"))?;
|
|
}
|
|
// MDM uses the raw value (fail_closed intact) so it's enforced like the files.
|
|
if let Some(mdm) = crate::macos_managed::managed_preferences_requirements() {
|
|
validate_requirements_value(mdm, &RequirementsSource::Mdm)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate the user requirements layer if a user home resolves; otherwise a
|
|
/// no-op (no cwd-relative `.kigi/requirements.toml` is read or enforced).
|
|
fn validate_user_requirements(home: Option<&Path>) -> Result<(), RequirementsError> {
|
|
match home {
|
|
Some(g) => validate_requirements_layer(&g.join("requirements.toml")),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// `fail_closed` for [`validate_requirements`]'s version check: the admin file flag is authoritative; the env can only TIGHTEN it (force-on), never loosen.
|
|
fn resolve_fail_closed_mode(requirements: &toml::Value) -> bool {
|
|
fail_closed_flag(requirements) || env_bool(FAIL_CLOSED_ENV) == Some(true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Even with `fail_closed = true` in the file -- enforcement is
|
|
/// `validate_requirements`, not the loader.
|
|
#[test]
|
|
fn load_requirements_layer_soft_fails_on_invalid_version_overrides() {
|
|
use std::io::Write;
|
|
|
|
let dir = std::env::temp_dir().join(format!("kigi-vo-soft-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("requirements.toml");
|
|
let mut f = std::fs::File::create(&path).unwrap();
|
|
writeln!(
|
|
f,
|
|
r#"
|
|
fail_closed = true
|
|
[[version_overrides]]
|
|
minimum_version = "not-a-version"
|
|
[version_overrides.features]
|
|
telemetry = true
|
|
"#
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(load_requirements_layer(&path).is_none());
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_requirements_layer_errs_on_fail_closed_violation() {
|
|
use std::io::Write;
|
|
|
|
let dir = std::env::temp_dir().join(format!("kigi-vo-validate-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("requirements.toml");
|
|
let mut f = std::fs::File::create(&path).unwrap();
|
|
writeln!(
|
|
f,
|
|
r#"
|
|
fail_closed = true
|
|
[[version_overrides]]
|
|
minimum_version = "not-a-version"
|
|
"#
|
|
)
|
|
.unwrap();
|
|
|
|
let err = validate_requirements_layer(&path).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
RequirementsError::InvalidVersionOverrides { .. }
|
|
));
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_requirements_layer_ok_without_fail_closed() {
|
|
use std::io::Write;
|
|
|
|
let dir = std::env::temp_dir().join(format!("kigi-vo-soft2-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("requirements.toml");
|
|
let mut f = std::fs::File::create(&path).unwrap();
|
|
writeln!(
|
|
f,
|
|
r#"
|
|
[[version_overrides]]
|
|
minimum_version = "not-a-version"
|
|
"#
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(validate_requirements_layer(&path).is_ok());
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn fail_closed_env_can_tighten_but_not_loosen() {
|
|
// SAFETY: process-global env mutation, restored before return.
|
|
let off: toml::Value = toml::from_str("fail_closed = false\n").unwrap();
|
|
let on: toml::Value = toml::from_str("fail_closed = true\n").unwrap();
|
|
let prior = std::env::var(FAIL_CLOSED_ENV).ok();
|
|
|
|
// env=1 force-enables even when the file is off (tighten is allowed).
|
|
unsafe { std::env::set_var(FAIL_CLOSED_ENV, "1") };
|
|
assert!(resolve_fail_closed_mode(&off));
|
|
assert!(resolve_fail_closed_mode(&on));
|
|
|
|
// env=0 must NOT disable an admin's fail_closed=true (no local bypass).
|
|
unsafe { std::env::set_var(FAIL_CLOSED_ENV, "0") };
|
|
assert!(
|
|
resolve_fail_closed_mode(&on),
|
|
"a local env must not loosen admin fail_closed"
|
|
);
|
|
assert!(!resolve_fail_closed_mode(&off));
|
|
|
|
// Unset → the admin file flag governs.
|
|
unsafe { std::env::remove_var(FAIL_CLOSED_ENV) };
|
|
assert!(resolve_fail_closed_mode(&on));
|
|
assert!(!resolve_fail_closed_mode(&off));
|
|
|
|
unsafe {
|
|
match prior {
|
|
Some(p) => std::env::set_var(FAIL_CLOSED_ENV, p),
|
|
None => std::env::remove_var(FAIL_CLOSED_ENV),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fail_closed_flag_from_str_reads_the_opt_in() {
|
|
assert!(fail_closed_flag_from_str("fail_closed = true\n"));
|
|
assert!(!fail_closed_flag_from_str("fail_closed = false\n"));
|
|
// Missing key, a non-bool value, malformed TOML, and empty all read as
|
|
// not-opted-in (best-effort false) rather than panicking.
|
|
assert!(!fail_closed_flag_from_str("[features]\ntelemetry = true\n"));
|
|
assert!(!fail_closed_flag_from_str("fail_closed = \"yes\"\n"));
|
|
assert!(!fail_closed_flag_from_str("[unclosed"));
|
|
assert!(!fail_closed_flag_from_str(""));
|
|
}
|
|
|
|
#[test]
|
|
fn fail_closed_key_is_stripped_from_returned_layer() {
|
|
use std::io::Write;
|
|
|
|
let dir = std::env::temp_dir().join(format!("kigi-vo-strip-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("requirements.toml");
|
|
let mut f = std::fs::File::create(&path).unwrap();
|
|
writeln!(f, "fail_closed = true\n[features]\ntelemetry = true\n").unwrap();
|
|
|
|
let result = load_requirements_layer(&path).unwrap();
|
|
assert!(
|
|
result.get(FAIL_CLOSED_KEY).is_none(),
|
|
"fail_closed must not leak into the returned config"
|
|
);
|
|
assert_eq!(result["features"]["telemetry"].as_bool(), Some(true));
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn load_user_requirements_is_none_without_user_home() {
|
|
// No resolvable user home => no user requirements (no cwd-relative read).
|
|
assert!(load_user_requirements(None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn load_user_requirements_reads_layer_when_home_present() {
|
|
use std::io::Write;
|
|
|
|
let dir = std::env::temp_dir().join(format!("kigi-req-load-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut f = std::fs::File::create(dir.join("requirements.toml")).unwrap();
|
|
writeln!(f, "[features]\ntelemetry = true\n").unwrap();
|
|
|
|
let v = load_user_requirements(Some(&dir)).expect("layer present");
|
|
assert_eq!(v["features"]["telemetry"].as_bool(), Some(true));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_user_requirements_ok_without_user_home() {
|
|
// No user home => nothing to validate, no error.
|
|
assert!(validate_user_requirements(None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_user_requirements_errs_on_fail_closed_violation() {
|
|
use std::io::Write;
|
|
|
|
let dir = std::env::temp_dir().join(format!("kigi-req-validate-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut f = std::fs::File::create(dir.join("requirements.toml")).unwrap();
|
|
writeln!(
|
|
f,
|
|
r#"
|
|
fail_closed = true
|
|
[[version_overrides]]
|
|
minimum_version = "not-a-version"
|
|
"#
|
|
)
|
|
.unwrap();
|
|
|
|
let err = validate_user_requirements(Some(&dir)).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
RequirementsError::InvalidVersionOverrides { .. }
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// The macOS MDM layer enters at the value level (no file on disk):
|
|
/// `mdm_requirements_value` / `validate_requirements` hand the CFPreferences
|
|
/// value straight to these, so normalize (strip `fail_closed`, keep the
|
|
/// clamp) and enforcement (Err under `fail_closed` + a bad override) must
|
|
/// hold with no file in the loop.
|
|
#[test]
|
|
fn mdm_value_normalizes_and_enforces_like_a_file() {
|
|
let source = crate::macos_managed::MDM_REQUIREMENTS_SOURCE;
|
|
|
|
// Effective view: fail_closed stripped, the forced clamp kept.
|
|
let raw: toml::Value =
|
|
toml::from_str("fail_closed = true\n[features]\nweb_fetch = false\n").unwrap();
|
|
let normalized = normalize_requirements_value(raw, source).unwrap();
|
|
assert!(normalized.get(FAIL_CLOSED_KEY).is_none());
|
|
assert_eq!(normalized["features"]["web_fetch"].as_bool(), Some(false));
|
|
|
|
// Enforcement keeps fail_closed: a bad override under fail_closed => Err,
|
|
// the same override without fail_closed soft-fails (Ok).
|
|
let bad: toml::Value = toml::from_str(
|
|
"fail_closed = true\n[[version_overrides]]\nminimum_version = \"not-a-version\"\n",
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
validate_requirements_value(bad, &RequirementsSource::Mdm).unwrap_err(),
|
|
RequirementsError::InvalidVersionOverrides { .. }
|
|
));
|
|
let soft: toml::Value =
|
|
toml::from_str("[[version_overrides]]\nminimum_version = \"not-a-version\"\n").unwrap();
|
|
assert!(validate_requirements_value(soft, &RequirementsSource::Mdm).is_ok());
|
|
}
|
|
}
|