- 3888
if over.gateway.pending_expiry_days.is_some() { - 3889
base.gateway.pending_expiry_days = over.gateway.pending_expiry_days; - 3890
} - 3891
if over.memory.search_enabled.is_some() { - 3892
base.memory.search_enabled = over.memory.search_enabled; - 3893
} - 3894
if over.memory.write_enabled.is_some() { - 3895
base.memory.write_enabled = over.memory.write_enabled; - 3896
} - 3897
if over.memory.skill_proposals.is_some() { - 3898
base.memory.skill_proposals = over.memory.skill_proposals; - 3899
} - 3900
if over.memory.reflection.is_some() { - 3901
base.memory.reflection = over.memory.reflection; - 3902
} - 3903
if over.sandbox.backend.is_some() { - 3904
base.sandbox.backend = over.sandbox.backend; - 3905
} - 3906
if over.sandbox.image.is_some() { - 3907
base.sandbox.image = over.sandbox.image; - 3908
} - 3909
if over.finops.max_run_usd.is_some() { - 3910
base.finops.max_run_usd = over.finops.max_run_usd; - 3911
} - 3912
if over.finops.max_day_usd.is_some() { - 3913
base.finops.max_day_usd = over.finops.max_day_usd; - 3914
} - 3915
for (k, v) in over.finops.price_overrides { - 3916
base.finops.price_overrides.insert(k, v); - 3917
} - 3918
if over.goal.handoff_reset.is_some() { - 3919
base.goal.handoff_reset = over.goal.handoff_reset; - 3920
} - 3921
if over.goal.max_audit_blocks.is_some() { - 3922
base.goal.max_audit_blocks = over.goal.max_audit_blocks; - 3923
} - 3924
if over.work.enabled.is_some() { - 3925
base.work.enabled = over.work.enabled; - 3926
} - 3927
if over.work.default_mode.is_some() { - 3928
base.work.default_mode = over.work.default_mode; - 3929
} - 3930
if over.work.max_items.is_some() { - 3931
base.work.max_items = over.work.max_items; - 3932
} - 3933
if over.work.max_revisions.is_some() { - 3934
base.work.max_revisions = over.work.max_revisions; - 3935
} - 3936
if over.work.max_parallel.is_some() { - 3937
base.work.max_parallel = over.work.max_parallel; - 3938
} - 3939
if over.work.confirmation.is_some() { - 3940
base.work.confirmation = over.work.confirmation; - 3941
} - 3942
if over.route.objective.is_some() { - 3943
base.route.objective = over.route.objective; - 3944
} - 3945
for m in over.route.fallback_models { - 3946
if !base.route.fallback_models.contains(&m) { - 3947
base.route.fallback_models.push(m); - 3948
} - 3949
} - 3950
if over.route.max_fallbacks.is_some() { - 3951
base.route.max_fallbacks = over.route.max_fallbacks; - 3952
} - 3953
if over.probe.hosted.is_some() { - 3954
base.probe.hosted = over.probe.hosted; - 3955
} - 3956
if over.providers.ollama.keep_alive.is_some() { - 3957
base.providers.ollama.keep_alive = over.providers.ollama.keep_alive; - 3958
} - 3959
if over.providers.ollama.num_ctx.is_some() { - 3960
base.providers.ollama.num_ctx = over.providers.ollama.num_ctx; - 3961
} - 3962
if over.providers.anthropic.fast_mode.is_some() { - 3963
base.providers.anthropic.fast_mode = over.providers.anthropic.fast_mode; - 3964
} - 3965
if over.intent.enabled.is_some() { - 3966
base.intent.enabled = over.intent.enabled; - 3967
} - 3968
if over.intent.accept_confidence.is_some() { - 3969
base.intent.accept_confidence = over.intent.accept_confidence; - 3970
} - 3971
if over.intent.provisional_confidence.is_some() { - 3972
base.intent.provisional_confidence = over.intent.provisional_confidence; - 3973
} - 3974
if over.intent.slice_capabilities.is_some() { - 3975
base.intent.slice_capabilities = over.intent.slice_capabilities; - 3976
} - 3977
if over.intent.posture.is_some() { - 3978
base.intent.posture = over.intent.posture; - 3979
} - 3980
if over.intent.escalate.is_some() { - 3981
base.intent.escalate = over.intent.escalate; - 3982
} - 3983
if over.intent.classify_model.is_some() { - 3984
base.intent.classify_model = over.intent.classify_model; - 3985
} - 3986
if over.intent.max_classify_usd.is_some() { - 3987
base.intent.max_classify_usd = over.intent.max_classify_usd; - 3988
} - 3989
if over.intent.classify_timeout_secs.is_some() { - 3990
base.intent.classify_timeout_secs = over.intent.classify_timeout_secs; - 3991
} - 3992
if over.intent.autonomy.is_some() { - 3993
base.intent.autonomy = over.intent.autonomy; - 3994
} - 3995
if over.intent.evidence_max_age_secs.is_some() { - 3996
base.intent.evidence_max_age_secs = over.intent.evidence_max_age_secs; - 3997
} - 3998
if over.commitment.enabled.is_some() { - 3999
base.commitment.enabled = over.commitment.enabled; - 4000
} - 4001
if over.commitment.lifetime_budget_usd.is_some() { - 4002
base.commitment.lifetime_budget_usd = over.commitment.lifetime_budget_usd; - 4003
} - 4004
if over.commitment.stall_limit.is_some() { - 4005
base.commitment.stall_limit = over.commitment.stall_limit; - 4006
} - 4007
if over.commitment.review_every_hours.is_some() { - 4008
base.commitment.review_every_hours = over.commitment.review_every_hours; - 4009
} - 4010
if over.commitment.default_ttl_days.is_some() { - 4011
base.commitment.default_ttl_days = over.commitment.default_ttl_days; - 4012
} - 4013
for h in over.route.quality_hints { - 4014
if !base.route.quality_hints.contains(&h) { - 4015
base.route.quality_hints.push(h); - 4016
} - 4017
} - 4018
for h in over.route.modality_hints { - 4019
if !base.route.modality_hints.contains(&h) { - 4020
base.route.modality_hints.push(h); - 4021
} - 4022
} - 4023
if over.automation.catch_up_missed.is_some() { - 4024
base.automation.catch_up_missed = over.automation.catch_up_missed; - 4025
} - 4026
if over.update.url.is_some() { - 4027
base.update.url = over.update.url; - 4028
} - 4029
if over.update.interval_hours.is_some() { - 4030
base.update.interval_hours = over.update.interval_hours; - 4031
} - 4032
if over.tools.web_fetch.is_some() { - 4033
base.tools.web_fetch = over.tools.web_fetch; - 4034
} - 4035
if over.tools.browse.is_some() { - 4036
base.tools.browse = over.tools.browse; - 4037
} - 4038
if over.heartbeat.enabled.is_some() { - 4039
base.heartbeat.enabled = over.heartbeat.enabled; - 4040
} - 4041
if over.heartbeat.interval_secs.is_some() { - 4042
base.heartbeat.interval_secs = over.heartbeat.interval_secs; - 4043
} - 4044
if over.heartbeat.model.is_some() { - 4045
base.heartbeat.model = over.heartbeat.model; - 4046
} - 4047
if over.heartbeat.quiet_hours.is_some() { - 4048
base.heartbeat.quiet_hours = over.heartbeat.quiet_hours; - 4049
} - 4050
if over.heartbeat.max_findings.is_some() { - 4051
base.heartbeat.max_findings = over.heartbeat.max_findings; - 4052
} - 4053
for (name, hook) in over.gateway.outbound.webhooks { - 4054
base.gateway.outbound.webhooks.insert(name, hook); - 4055
} - 4056
if over.feeds.enabled.is_some() { - 4057
base.feeds.enabled = over.feeds.enabled; - 4058
} - 4059
if over.feeds.config_path.is_some() { - 4060
base.feeds.config_path = over.feeds.config_path; - 4061
} - 4062
if over.feeds.db_path.is_some() { - 4063
base.feeds.db_path = over.feeds.db_path; - 4064
} - 4065
if over.feeds.default_check_interval.is_some() { - 4066
base.feeds.default_check_interval = over.feeds.default_check_interval; - 4067
} - 4068
if over.feeds.max_items_per_feed.is_some() { - 4069
base.feeds.max_items_per_feed = over.feeds.max_items_per_feed; - 4070
} - 4071
if over.feeds.dedup_window_days.is_some() { - 4072
base.feeds.dedup_window_days = over.feeds.dedup_window_days; - 4073
} - 4074
for p in over.plugins.enabled { - 4075
if !base.plugins.enabled.contains(&p) { - 4076
base.plugins.enabled.push(p); - 4077
} - 4078
} - 4079
for p in over.plugins.disabled { - 4080
if !base.plugins.disabled.contains(&p) { - 4081
base.plugins.disabled.push(p); - 4082
} - 4083
} - 4084
if over.plugins.allow.is_some() { - 4085
base.plugins.allow = over.plugins.allow; - 4086
} - 4087
for p in over.plugins.deny { - 4088
if !base.plugins.deny.contains(&p) { - 4089
base.plugins.deny.push(p); - 4090
} - 4091
} - 4092
if over.plugins.network_allow.is_some() { - 4093
base.plugins.network_allow = over.plugins.network_allow; - 4094
} - 4095
for p in over.plugins.network_deny { - 4096
if !base.plugins.network_deny.contains(&p) { - 4097
base.plugins.network_deny.push(p); - 4098
} - 4099
} - 4100
for (k, v) in over.profiles { - 4101
base.profiles.insert(k, v); - 4102
} - 4103
} - 4104
- 4105
/// Process-wide extra environment sourced from .env files. Real - 4106
/// environment variables always take precedence. - 4107
type ExtraMap = std::collections::BTreeMap<String, String>; - 4108
- 4109
fn dotenv_extra() -> std::sync::MutexGuard<'static, ExtraMap> { - 4110
static EXTRA: std::sync::OnceLock<std::sync::Mutex<ExtraMap>> = std::sync::OnceLock::new(); - 4111
EXTRA - 4112
.get_or_init(|| std::sync::Mutex::new(ExtraMap::new())) - 4113
.lock() - 4114
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4115
} - 4116
- 4117
/// Loads a scope's stored secrets (docs/design/44-shared-config.md, - 4118
/// "Secrets Chain") into the extra-env table. `path` is a scope hint — the - 4119
/// directory that used to hold a literal `.env` file — not a file read - 4120
/// directly; see [`credentials`]. Existing real environment variables are - 4121
/// never overridden. Only the encrypted-file credential backend can - 4122
/// enumerate a scope's contents; an OS-native secret service is reached by - 4123
/// point lookup only, so bulk-seeding this cache has no effect there (see - 4124
/// `CredentialStore::list`) — callers needing a specific key from that - 4125
/// backend should resolve it explicitly instead of relying on this cache. - 4126
pub fn load_env_file(path: &std::path::Path) { - 4127
// `credentials::list` can itself take the `dotenv_extra` lock further - 4128
// down (e.g. `EncryptedFileStore::new` reads `VAK_HOME` via `get_var` - 4129
// on first use) — it must run to completion BEFORE this function takes - 4130
// that lock itself, or a thread deadlocks against its own held guard. - 4131
let entries = credentials::list(path); - 4132
let mut extra = dotenv_extra(); - 4133
for (key, value) in entries { - 4134
extra.entry(key).or_insert(value); - 4135
} - 4136
} - 4137
- 4138
/// Replaces credential-sourced environment values as one atomic scope - 4139
/// change. Runtime overrides and real environment variables remain - 4140
/// untouched. - 4141
pub fn replace_env_files(paths: &[&std::path::Path]) { - 4142
// Same ordering requirement as `load_env_file` above: resolve every - 4143
// scope's entries before touching the `dotenv_extra` lock. - 4144
let entries: Vec<_> = paths - 4145
.iter() - 4146
.flat_map(|path| credentials::list(path)) - 4147
.collect(); - 4148
let mut extra = dotenv_extra(); - 4149
extra.clear(); - 4150
for (key, value) in entries { - 4151
extra.entry(key).or_insert(value); - 4152
} - 4153
} - 4154
- 4155
/// Environment lookup: runtime overrides first (values set this session, - 4156
/// e.g. a key the user just saved), then real env, then loaded .env files. - 4157
pub fn get_var(key: &str) -> Option<String> { - 4158
var_overrides() - 4159
.get(key) - 4160
.cloned() - 4161
.or_else(|| std::env::var(key).ok()) - 4162
.or_else(|| dotenv_extra().get(key).cloned()) - 4163
} - 4164
- 4165
fn var_overrides() -> std::sync::MutexGuard<'static, ExtraMap> { - 4166
static OVERRIDES: std::sync::OnceLock<std::sync::Mutex<ExtraMap>> = std::sync::OnceLock::new(); - 4167
OVERRIDES - 4168
.get_or_init(|| std::sync::Mutex::new(ExtraMap::new())) - 4169
.lock() - 4170
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4171
} - 4172
- 4173
/// Registers a value with the highest lookup precedence for THIS process. - 4174
/// Persistence is the caller's job (see `upsert_env_file`); overrides die - 4175
/// with the process, and a real environment variable set after this call - 4176
/// still loses to it until the process restarts. - 4177
pub fn set_override(key: impl Into<String>, value: impl Into<String>) { - 4178
var_overrides().insert(key.into(), value.into()); - 4179
} - 4180
- 4181
/// Drops a runtime override so lookups fall back to the real environment - 4182
/// and .env files again. Used when a key is revoked mid-session. - 4183
pub fn clear_override(key: &str) { - 4184
var_overrides().remove(key); - 4185
} - 4186
- 4187
/// Forgets a key loaded from a .env file earlier this session. Without - 4188
/// this a revoked key keeps resolving from the in-memory dotenv map. - 4189
pub fn forget_dotenv_var(key: &str) { - 4190
dotenv_extra().remove(key); - 4191
} - 4192
- 4193
/// Scope hint for the shared secret layer inherited by every workspace - 4194
/// (docs/design/44-shared-config.md, "Secrets Chain"). No file is written - 4195
/// at this literal path anymore — it only identifies the scope passed to - 4196
/// [`credentials`]; kept as a `~/vak-home/.env`-shaped path so existing - 4197
/// callers' scoping (same directory = same layer) is unchanged. - 4198
pub fn user_env_path() -> Option<std::path::PathBuf> { - 4199
Some(crate::paths::default_workspace().join(".env")) - 4200
} - 4201
- 4202
/// Reads one credential from a specific scope without merging it into the - 4203
/// process-wide environment cache. Scoped MCP credentials use this so two - 4204
/// pooled workspaces can resolve different values for the same variable. - 4205
/// `path` is a scope hint (previously a literal `.env` path), not a file - 4206
/// read directly — see [`credentials`]. - 4207
pub fn read_env_file_var(path: &std::path::Path, key: &str) -> Option<String> { - 4208
credentials::get(path, key) - 4209
} - 4210
- 4211
/// Removes `key` from the scope named by `path`. A missing scope or key is - 4212
/// a no-op success. - 4213
pub fn remove_env_file_key(path: &std::path::Path, key: &str) -> std::io::Result<()> { - 4214
credentials::remove(path, key) - 4215
} - 4216
- 4217
/// Upserts `key = value` into the scope named by `path`, persisted through - 4218
/// whichever [`credentials::CredentialStore`] backend this host resolved - 4219
/// (OS-native secret service, or the encrypted-file fallback) — never as - 4220
/// plaintext. - 4221
pub fn upsert_env_file(path: &std::path::Path, key: &str, value: &str) -> std::io::Result<()> { - 4222
credentials::set(path, key, value) - 4223
} - 4224
- 4225
#[cfg(test)] - 4226
#[allow( - 4227
clippy::unwrap_used, - 4228
clippy::expect_used, - 4229
clippy::field_reassign_with_default - 4230
)] - 4231
mod tests { - 4232
#[test] - 4233
fn voice_defaults_are_safe_and_serializable() { - 4234
let settings = VoiceSettings::default(); - 4235
assert!(!settings.enabled); - 4236
assert_eq!(settings.max_session_secs, 900); - 4237
assert_eq!(settings.max_concurrent, 2); - 4238
assert_eq!(settings.max_audio_bytes, 16 * 1024 * 1024); - 4239
assert_eq!(settings.max_requests_per_minute, 60); - 4240
assert_eq!(settings.max_text_chars, 100_000); - 4241
assert!(settings.validate().is_ok()); - 4242
let encoded = toml::to_string(&settings).expect("voice settings serialize"); - 4243
let decoded: VoiceSettings = toml::from_str(&encoded).expect("voice settings deserialize"); - 4244
assert_eq!(decoded, settings); - 4245
let routed = VoiceSettings { - 4246
provider: Some("openai".into()), - 4247
transcription_model: Some("stt-v1".into()), - 4248
..settings - 4249
}; - 4250
let encoded = toml::to_string(&routed).expect("routed voice settings serialize"); - 4251
let decoded: VoiceSettings = - 4252
toml::from_str(&encoded).expect("routed voice settings deserialize"); - 4253
assert_eq!(decoded.provider.as_deref(), Some("openai")); - 4254
assert_eq!(decoded.transcription_model.as_deref(), Some("stt-v1")); - 4255
assert!(decoded.validate().is_ok()); - 4256
} - 4257
- 4258
#[test] - 4259
fn ollama_settings_validate_accepts_go_style_durations() { - 4260
for keep_alive in ["10m", "24h", "0", "1h30m", "500ms", "90s"] { - 4261
let settings = OllamaSettings { - 4262
keep_alive: Some(keep_alive.to_string()), - 4263
num_ctx: Some(4096), - 4264
}; - 4265
assert!(settings.validate().is_ok(), "{keep_alive} should be valid"); - 4266
} - 4267
} - 4268
- 4269
#[test] - 4270
fn ollama_settings_validate_rejects_malformed_duration_and_small_num_ctx() { - 4271
let settings = OllamaSettings { - 4272
keep_alive: Some("forever".into()), - 4273
num_ctx: None, - 4274
}; - 4275
assert!(settings.validate().is_err()); - 4276
- 4277
let settings = OllamaSettings { - 4278
keep_alive: None, - 4279
num_ctx: Some(1023), - 4280
}; - 4281
assert!(settings.validate().is_err()); - 4282
- 4283
let settings = OllamaSettings { - 4284
keep_alive: None, - 4285
num_ctx: Some(1024), - 4286
}; - 4287
assert!(settings.validate().is_ok()); - 4288
} - 4289
- 4290
#[test] - 4291
fn voice_validation_rejects_zero_and_excessive_limits() { - 4292
let mut settings = VoiceSettings::default(); - 4293
settings.max_concurrent = 0; - 4294
assert!(settings.validate().is_err()); - 4295
settings = VoiceSettings::default(); - 4296
settings.max_audio_bytes = 512 * 1024 * 1024; - 4297
assert!(settings.validate().is_err()); - 4298
settings = VoiceSettings::default(); - 4299
settings.max_requests_per_minute = 0; - 4300
assert!(settings.validate().is_err()); - 4301
settings = VoiceSettings::default(); - 4302
settings.max_text_chars = 0; - 4303
assert!(settings.validate().is_err()); - 4304
settings = VoiceSettings { - 4305
provider: Some(" ".into()), - 4306
..VoiceSettings::default() - 4307
}; - 4308
assert!(settings.validate().is_err()); - 4309
settings = VoiceSettings { - 4310
synthesis_model: Some("x".repeat(257)), - 4311
..VoiceSettings::default() - 4312
}; - 4313
assert!(settings.validate().is_err()); - 4314
} - 4315
- 4316
#[test] - 4317
fn voice_patch_sets_clears_and_preserves_keys() { - 4318
let dir = tempfile::tempdir().unwrap(); - 4319
let path = dir.path().join("config.toml"); - 4320
std::fs::write( - 4321
&path, - 4322
"[voice]\nprovider = \"gemini\"\nsynthesis_model = \"tts-a\"\nfuture_key = 7\n", - 4323
) - 4324
.unwrap(); - 4325
persist_voice_settings_at( - 4326
path.clone(), - 4327
&VoicePatch { - 4328
enabled: Some(true), - 4329
transcription_model: Some(Some("stt-a".into())), - 4330
synthesis_model: Some(None), - 4331
..VoicePatch::default() - 4332
}, - 4333
) - 4334
.unwrap(); - 4335
let value: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4336
let voice = value["voice"].as_table().unwrap(); - 4337
assert_eq!(voice["enabled"].as_bool(), Some(true)); - 4338
assert_eq!(voice["provider"].as_str(), Some("gemini")); - 4339
assert_eq!(voice["transcription_model"].as_str(), Some("stt-a")); - 4340
assert!(!voice.contains_key("synthesis_model")); - 4341
assert_eq!(voice["future_key"].as_integer(), Some(7)); - 4342
assert!(VoicePatch::default().is_empty()); - 4343
} - 4344
use super::*; - 4345
- 4346
#[test] - 4347
fn ensure_project_config_creates_inheriting_layer_without_overwriting_it() { - 4348
let project = tempfile::tempdir().unwrap(); - 4349
let path = ensure_project_config(project.path()).unwrap(); - 4350
assert_eq!(path, project.path().join(".vak/config.toml")); - 4351
assert_eq!( - 4352
std::fs::read_to_string(&path).unwrap(), - 4353
"# Project-local overrides. Unset values inherit from the user config.\n" - 4354
); - 4355
- 4356
std::fs::write(&path, "provider = \"ollama\"\n").unwrap(); - 4357
ensure_project_config(project.path()).unwrap(); - 4358
assert_eq!( - 4359
std::fs::read_to_string(path).unwrap(), - 4360
"provider = \"ollama\"\n" - 4361
); - 4362
} - 4363
- 4364
#[test] - 4365
fn evidence_policy_writer_preserves_other_layers_and_clamps_negative() { - 4366
let project = tempfile::tempdir().unwrap(); - 4367
let path = project.path().join(".vak/config.toml"); - 4368
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 4369
std::fs::write(&path, "provider = \"ollama\"\n\n[ui]\ntheme = \"dark\"\n").unwrap(); - 4370
persist_evidence_max_age(path.clone(), -5).unwrap(); - 4371
let text = std::fs::read_to_string(path).unwrap(); - 4372
assert!(text.contains("provider = \"ollama\"")); - 4373
assert!(text.contains("theme = \"dark\"")); - 4374
assert!(text.contains("evidence_max_age_secs = 0")); - 4375
} - 4376
- 4377
#[test] - 4378
fn evidence_policy_resolves_from_project_layer() { - 4379
let project = tempfile::tempdir().unwrap(); - 4380
let path = project.path().join(".vak/config.toml"); - 4381
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 4382
std::fs::write(&path, "[intent]\nevidence_max_age_secs = 120\n").unwrap(); - 4383
let config = load_with_trust(project.path(), true).unwrap(); - 4384
assert_eq!(config.intent.evidence_max_age_secs, 120); - 4385
assert!(config.intent.enabled); - 4386
} - 4387
- 4388
/// `capped_by` is the single arithmetic the gateway's per-channel - 4389
/// permission override rests on: it must be a true `min` over the - 4390
/// permissiveness ranking, in both argument orders, for every pair. - 4391
/// The whole point of a scoped writer: it must not disturb keys it was - 4392
/// not asked about. This one had no writer at all, so the only way to - 4393
/// set it was hand-editing the file — and hand-editing is exactly what - 4394
/// loses the rest of the document to a typo. - 4395
#[test] - 4396
fn writing_gateway_approvals_leaves_every_other_key_alone() { - 4397
let dir = tempfile::tempdir().unwrap(); - 4398
let path = dir.path().join("config.toml"); - 4399
std::fs::write( - 4400
&path, - 4401
"provider = \"anthropic\"\npermission_mode = \"read-only\"\n\n [gateway]\nenabled = true\n\n[mcp.servers.thing]\ncommand = \"sh\"\n", - 4402
) - 4403
.unwrap(); - 4404
- 4405
persist_gateway_approvals( - 4406
path.clone(), - 4407
Some("forward"), - 4408
Some(Some("telegram:42")), - 4409
Some(120), - 4410
) - 4411
.unwrap(); - 4412
- 4413
let text = std::fs::read_to_string(&path).unwrap(); - 4414
let parsed: toml::Value = toml::from_str(&text).unwrap(); - 4415
let gw = parsed.get("gateway").unwrap(); - 4416
assert_eq!(gw.get("approvals").unwrap().as_str(), Some("forward")); - 4417
assert_eq!(gw.get("approver").unwrap().as_str(), Some("telegram:42")); - 4418
assert_eq!( - 4419
gw.get("approval_timeout_secs").unwrap().as_integer(), - 4420
Some(120) - 4421
); - 4422
assert_eq!(gw.get("enabled").unwrap().as_bool(), Some(true)); - 4423
assert_eq!(parsed.get("provider").unwrap().as_str(), Some("anthropic")); - 4424
assert!(parsed.get("mcp").is_some(), "unrelated tables survive"); - 4425
} - 4426
- 4427
/// `Some(None)` clears the target, so returning to `deny` cannot leave a - 4428
/// stale chat behind for a later `forward` to reuse silently. - 4429
#[test] - 4430
fn clearing_the_approver_removes_the_key() { - 4431
let dir = tempfile::tempdir().unwrap(); - 4432
let path = dir.path().join("config.toml"); - 4433
persist_gateway_approvals( - 4434
path.clone(), - 4435
Some("forward"), - 4436
Some(Some("telegram:1")), - 4437
None, - 4438
) - 4439
.unwrap(); - 4440
persist_gateway_approvals(path.clone(), Some("deny"), Some(None), None).unwrap(); - 4441
- 4442
let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4443
let gw = parsed.get("gateway").unwrap(); - 4444
assert_eq!(gw.get("approvals").unwrap().as_str(), Some("deny")); - 4445
assert!(gw.get("approver").is_none()); - 4446
} - 4447
- 4448
/// Absent means "leave alone"; present replaces the list wholesale, so - 4449
/// an empty vec is how a list is cleared. - 4450
#[test] - 4451
fn writing_rule_lists_replaces_only_the_lists_named() { - 4452
let dir = tempfile::tempdir().unwrap(); - 4453
let path = dir.path().join("config.toml"); - 4454
std::fs::write(&path, "allow = [\"Bash(git *)\"]\nask = [\"Edit(~/**)\"]\n").unwrap(); - 4455
- 4456
persist_permission_rules(path.clone(), None, None, Some(&["Bash(rm *)".to_string()])) - 4457
.unwrap(); - 4458
let cfg: FileConfig = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4459
assert_eq!(cfg.allow, vec!["Bash(git *)"], "untouched"); - 4460
assert_eq!(cfg.ask, vec!["Edit(~/**)"], "untouched"); - 4461
assert_eq!(cfg.deny, vec!["Bash(rm *)"]); - 4462
- 4463
persist_permission_rules(path.clone(), Some(&[]), None, None).unwrap(); - 4464
let cfg: FileConfig = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4465
assert!(cfg.allow.is_empty(), "an empty list clears"); - 4466
assert_eq!(cfg.deny, vec!["Bash(rm *)"], "and still nothing else moved"); - 4467
} - 4468
- 4469
#[test] - 4470
fn capped_by_is_min_over_permissiveness_and_never_escalates() { - 4471
use PermissionMode::*; - 4472
let all = [ReadOnly, WorkspaceWrite, FullAccess]; - 4473
assert!(ReadOnly.rank() < WorkspaceWrite.rank()); - 4474
assert!(WorkspaceWrite.rank() < FullAccess.rank()); - 4475
for requested in all { - 4476
for ceiling in all { - 4477
let got = requested.capped_by(ceiling); - 4478
// Never more permissive than the ceiling — the whole point. - 4479
assert!( - 4480
got.rank() <= ceiling.rank(), - 4481
"{requested:?} capped by {ceiling:?} escalated to {got:?}" - 4482
); - 4483
// Never more permissive than what was asked for either. - 4484
assert!(got.rank() <= requested.rank()); - 4485
// And it is exactly the min, not an over-eager clamp. - 4486
assert_eq!(got.rank(), requested.rank().min(ceiling.rank())); - 4487
} - 4488
} - 4489
// Concrete spot checks of the security-relevant direction. - 4490
assert_eq!(FullAccess.capped_by(ReadOnly), ReadOnly); - 4491
assert_eq!(FullAccess.capped_by(WorkspaceWrite), WorkspaceWrite); - 4492
assert_eq!(ReadOnly.capped_by(FullAccess), ReadOnly); - 4493
assert_eq!(WorkspaceWrite.capped_by(WorkspaceWrite), WorkspaceWrite); - 4494
} - 4495
- 4496
/// `ChannelPolicy::merge` is the bot→chat fold used at dispatch - 4497
/// (`core_for_entry`): the chat's own `_allow` wins when set, denies - 4498
/// concatenate, and an unset chat field falls back to the bot's. - 4499
#[test] - 4500
fn channel_policy_merge_lets_chat_allow_win_and_denies_accumulate() { - 4501
let bot = ChannelPolicy { - 4502
tools_allow: Some(vec!["read".into(), "write".into()]), - 4503
tools_deny: vec!["shell".into()], - 4504
..ChannelPolicy::default() - 4505
}; - 4506
let chat = ChannelPolicy { - 4507
tools_allow: Some(vec!["read".into()]), - 4508
tools_deny: vec!["fetch".into()], - 4509
..ChannelPolicy::default() - 4510
}; - 4511
let merged = ChannelPolicy::merge(&bot, &chat); - 4512
// Chat's narrower allow list wins outright. - 4513
assert_eq!(merged.tools_allow, Some(vec!["read".to_string()])); - 4514
// Denies from both tiers accumulate — restrictive-only. - 4515
assert_eq!( - 4516
merged.tools_deny, - 4517
vec!["shell".to_string(), "fetch".to_string()] - 4518
); - 4519
} - 4520
- 4521
#[test] - 4522
fn channel_policy_merge_falls_back_to_bot_when_chat_is_unset() { - 4523
let bot = ChannelPolicy { - 4524
mcp_allow: Some(vec!["search/*".into()]), - 4525
..ChannelPolicy::default() - 4526
}; - 4527
let chat = ChannelPolicy::default(); - 4528
let merged = ChannelPolicy::merge(&bot, &chat); - 4529
assert_eq!(merged.mcp_allow, Some(vec!["search/*".to_string()])); - 4530
} - 4531
- 4532
#[test] - 4533
fn channel_policy_merge_of_two_defaults_is_default() { - 4534
assert_eq!( - 4535
ChannelPolicy::merge(&ChannelPolicy::default(), &ChannelPolicy::default()), - 4536
ChannelPolicy::default() - 4537
); - 4538
} - 4539
- 4540
fn write_project_config(dir: &Path, text: &str) { - 4541
let project = dir.join(".vak"); - 4542
std::fs::create_dir_all(&project).expect("project dir"); - 4543
std::fs::write(project.join("config.toml"), text).expect("write config"); - 4544
} - 4545
- 4546
#[test] - 4547
fn automation_update_tools_default_when_absent() { - 4548
let dir = tempfile::tempdir().unwrap(); - 4549
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4550
assert!(cfg.automation.catch_up_missed); - 4551
assert_eq!(cfg.update.url, None); - 4552
assert_eq!(cfg.update.interval_hours, 24); - 4553
assert!(cfg.tools.web_fetch); - 4554
assert!(cfg.tools.browse); - 4555
assert!( - 4556
!cfg.warnings.iter().any(|w| w.contains("automation")), - 4557
"absent sections must not warn: {:?}", - 4558
cfg.warnings - 4559
); - 4560
} - 4561
- 4562
#[test] - 4563
fn work_settings_parse_and_unknown_keys_warn() { - 4564
let dir = tempfile::tempdir().unwrap(); - 4565
write_project_config( - 4566
dir.path(), - 4567
"[work]\nenabled = true\ndefault_mode = \"managed\"\nmax_items = 12\nmax_revisions = 5\nmax_parallel = 3\nconfirmation = \"always\"\nunknown = true\n", - 4568
); - 4569
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4570
assert!(cfg.work.enabled); - 4571
assert_eq!(cfg.work.default_mode, "managed"); - 4572
assert_eq!(cfg.work.max_items, 12); - 4573
assert_eq!(cfg.work.max_revisions, 5); - 4574
assert_eq!(cfg.work.max_parallel, 3); - 4575
assert_eq!(cfg.work.confirmation, "always"); - 4576
assert!( - 4577
cfg.warnings - 4578
.iter() - 4579
.any(|warning| warning.contains("work.unknown")) - 4580
); - 4581
} - 4582
- 4583
#[test] - 4584
fn intent_section_parses_and_clamps() { - 4585
let dir = tempfile::tempdir().unwrap(); - 4586
write_project_config( - 4587
dir.path(), - 4588
"[intent]\nenabled = true\naccept_confidence = 0.9\n\ - 4589
provisional_confidence = 0.99\nslice_capabilities = false\n\ - 4590
escalate = \"local\"\nautonomy = \"delegated\"\n", - 4591
); - 4592
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4593
assert!(cfg.intent.enabled); - 4594
assert_eq!(cfg.intent.accept_confidence, 0.9); - 4595
// Inverted thresholds clamp rather than break the ordering invariant. - 4596
assert!(cfg.intent.provisional_confidence <= cfg.intent.accept_confidence); - 4597
assert!(!cfg.intent.slice_capabilities); - 4598
assert_eq!(cfg.intent.escalate, "local"); - 4599
assert_eq!(cfg.intent.autonomy, "delegated"); - 4600
} - 4601
- 4602
#[test] - 4603
fn unknown_intent_values_warn_and_fall_back_to_the_safe_default() { - 4604
let dir = tempfile::tempdir().unwrap(); - 4605
write_project_config( - 4606
dir.path(), - 4607
"[intent]\nescalate = \"telepathy\"\nautonomy = \"unlimited\"\n", - 4608
); - 4609
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4610
assert_eq!(cfg.intent.escalate, "none"); - 4611
assert_eq!(cfg.intent.autonomy, "assisted"); - 4612
assert!(cfg.warnings.iter().any(|w| w.contains("intent.escalate"))); - 4613
assert!(cfg.warnings.iter().any(|w| w.contains("intent.autonomy"))); - 4614
} - 4615
- 4616
/// A cloned repository must not be able to grant itself the right to act - 4617
/// without asking, nor to spend the user's credentials classifying. - 4618
#[test] - 4619
fn an_untrusted_project_cannot_grant_itself_autonomy_or_a_paid_classifier() { - 4620
let dir = tempfile::tempdir().unwrap(); - 4621
write_project_config( - 4622
dir.path(), - 4623
"[intent]\nautonomy = \"autonomous\"\nescalate = \"cloud\"\n\ - 4624
slice_capabilities = false\n", - 4625
); - 4626
let cfg = load_with_trust(dir.path(), false).unwrap(); - 4627
assert_eq!(cfg.intent.autonomy, "assisted"); - 4628
assert_eq!(cfg.intent.escalate, "none"); - 4629
// The non-privileged half of the section still applies: choosing to - 4630
// see more of your own tools grants nothing. - 4631
assert!(!cfg.intent.slice_capabilities); - 4632
- 4633
// And with trust, the same file does take effect. - 4634
let trusted = load_with_trust(dir.path(), true).unwrap(); - 4635
assert_eq!(trusted.intent.autonomy, "autonomous"); - 4636
assert_eq!(trusted.intent.escalate, "cloud"); - 4637
} - 4638
- 4639
/// Switching the kernel or its posture off removes the approval floor it - 4640
/// raises for irreversible work; a cloned repository may not do that to - 4641
/// itself, and a trusted one still may. - 4642
#[test] - 4643
fn an_untrusted_project_cannot_switch_the_intent_floor_off() { - 4644
let dir = tempfile::tempdir().unwrap(); - 4645
write_project_config(dir.path(), "[intent]\nenabled = false\nposture = false\n"); - 4646
let cfg = load_with_trust(dir.path(), false).unwrap(); - 4647
assert!(cfg.intent.enabled); - 4648
assert!(cfg.intent.posture); - 4649
let trusted = load_with_trust(dir.path(), true).unwrap(); - 4650
assert!(!trusted.intent.enabled); - 4651
assert!(!trusted.intent.posture); - 4652
} - 4653
- 4654
/// TOML can spell `nan` and `inf`. A threshold or a cap every comparison - 4655
/// is false against would silently switch its check off, so a - 4656
/// non-finite value falls back to the default with a warning. - 4657
#[test] - 4658
fn non_finite_intent_numbers_fall_back_to_their_defaults() { - 4659
let dir = tempfile::tempdir().unwrap(); - 4660
write_project_config( - 4661
dir.path(), - 4662
"[intent]\naccept_confidence = nan\nprovisional_confidence = inf\n\ - 4663
max_classify_usd = nan\n[commitment]\nlifetime_budget_usd = nan\n", - 4664
); - 4665
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4666
assert_eq!(cfg.intent.accept_confidence, 0.75); - 4667
assert_eq!(cfg.intent.provisional_confidence, 0.45); - 4668
assert_eq!(cfg.intent.max_classify_usd, 0.01); - 4669
assert_eq!(cfg.commitment.lifetime_budget_usd, None); - 4670
assert!( - 4671
cfg.warnings - 4672
.iter() - 4673
.any(|w| w.contains("intent.accept_confidence")), - 4674
"{:?}", - 4675
cfg.warnings - 4676
); - 4677
} - 4678
- 4679
#[test] - 4680
fn an_untrusted_project_may_still_restrict_itself_to_a_local_classifier() { - 4681
let dir = tempfile::tempdir().unwrap(); - 4682
write_project_config(dir.path(), "[intent]\nescalate = \"local\"\n"); - 4683
let cfg = load_with_trust(dir.path(), false).unwrap(); - 4684
assert_eq!(cfg.intent.escalate, "local"); - 4685
} - 4686
- 4687
#[test] - 4688
fn commitment_section_parses() { - 4689
let dir = tempfile::tempdir().unwrap(); - 4690
write_project_config( - 4691
dir.path(), - 4692
"[commitment]\nlifetime_budget_usd = 25.0\nstall_limit = 5\n\ - 4693
review_every_hours = 24\ndefault_ttl_days = 30\n", - 4694
); - 4695
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4696
assert_eq!(cfg.commitment.lifetime_budget_usd, Some(25.0)); - 4697
assert_eq!(cfg.commitment.stall_limit, 5); - 4698
assert_eq!(cfg.commitment.review_every_hours, Some(24)); - 4699
assert_eq!(cfg.commitment.default_ttl_days, Some(30)); - 4700
} - 4701
- 4702
/// The kernel must be switchable off entirely, because "reproduce the old - 4703
/// behaviour exactly" has to remain one line of config. - 4704
#[test] - 4705
fn the_intent_kernel_can_be_switched_off() { - 4706
let dir = tempfile::tempdir().unwrap(); - 4707
write_project_config(dir.path(), "[intent]\nenabled = false\n"); - 4708
assert!(!load_with_trust(dir.path(), true).unwrap().intent.enabled); - 4709
} - 4710
- 4711
#[test] - 4712
fn automation_catch_up_missed_parses() { - 4713
let dir = tempfile::tempdir().unwrap(); - 4714
write_project_config(dir.path(), "[automation]\ncatch_up_missed = false\n"); - 4715
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4716
assert!(!cfg.automation.catch_up_missed); - 4717
} - 4718
- 4719
#[test] - 4720
fn update_url_and_interval_hours_parse() { - 4721
let dir = tempfile::tempdir().unwrap(); - 4722
write_project_config( - 4723
dir.path(), - 4724
"[update]\nurl = \"https://example.com/manifest.json\"\ninterval_hours = 6\n", - 4725
); - 4726
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4727
assert_eq!( - 4728
cfg.update.url.as_deref(), - 4729
Some("https://example.com/manifest.json") - 4730
); - 4731
assert_eq!(cfg.update.interval_hours, 6); - 4732
} - 4733
- 4734
#[test] - 4735
fn tools_web_fetch_switch_parses() { - 4736
let dir = tempfile::tempdir().unwrap(); - 4737
write_project_config(dir.path(), "[tools]\nweb_fetch = false\n"); - 4738
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4739
assert!(!cfg.tools.web_fetch); - 4740
} - 4741
- 4742
#[test] - 4743
fn tools_browse_switch_parses() { - 4744
let dir = tempfile::tempdir().unwrap(); - 4745
write_project_config(dir.path(), "[tools]\nbrowse = false\n"); - 4746
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4747
assert!(!cfg.tools.browse); - 4748
let dir2 = tempfile::tempdir().unwrap(); - 4749
write_project_config(dir2.path(), "[tools]\nbrowse = true\nweb_fetch = false\n"); - 4750
let cfg2 = load_with_trust(dir2.path(), true).unwrap(); - 4751
assert!(cfg2.tools.browse); - 4752
assert!(!cfg2.tools.web_fetch); - 4753
} - 4754
- 4755
#[test] - 4756
fn unknown_keys_in_new_sections_warn() { - 4757
let dir = tempfile::tempdir().unwrap(); - 4758
write_project_config( - 4759
dir.path(), - 4760
"[automation]\nbogus = 1\n\n[update]\nbogus = 1\n\n[tools]\nbogus = 1\n", - 4761
); - 4762
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4763
for key in ["automation.bogus", "update.bogus", "tools.bogus"] { - 4764
assert!( - 4765
cfg.warnings.iter().any(|w| w.contains(key)), - 4766
"unknown {key} must warn: {:?}", - 4767
cfg.warnings - 4768
); - 4769
} - 4770
assert!(cfg.automation.catch_up_missed); - 4771
assert_eq!(cfg.update.interval_hours, 24); - 4772
assert!(cfg.tools.web_fetch); - 4773
} - 4774
- 4775
#[test] - 4776
fn heartbeat_defaults_when_absent() { - 4777
let dir = tempfile::tempdir().unwrap(); - 4778
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4779
assert!(!cfg.heartbeat.enabled); - 4780
assert_eq!(cfg.heartbeat.interval_secs, 1800); - 4781
assert_eq!(cfg.heartbeat.model, None); - 4782
assert_eq!(cfg.heartbeat.quiet_hours, None); - 4783
assert_eq!(cfg.heartbeat.max_findings, 3); - 4784
assert!( - 4785
!cfg.warnings.iter().any(|w| w.contains("heartbeat")), - 4786
"absent heartbeat section must not warn: {:?}", - 4787
cfg.warnings - 4788
); - 4789
} - 4790
- 4791
#[test] - 4792
fn heartbeat_full_section_parses() { - 4793
let dir = tempfile::tempdir().unwrap(); - 4794
write_project_config( - 4795
dir.path(), - 4796
"[heartbeat]\nenabled = true\ninterval_secs = 900\nmodel = \"openai/gpt-5-mini\"\nquiet_hours = \"22:00-07:00\"\nmax_findings = 5\n", - 4797
); - 4798
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4799
assert!(cfg.heartbeat.enabled); - 4800
assert_eq!(cfg.heartbeat.interval_secs, 900); - 4801
assert_eq!(cfg.heartbeat.model.as_deref(), Some("openai/gpt-5-mini")); - 4802
assert_eq!( - 4803
cfg.heartbeat.quiet_hours, - 4804
Some(QuietWindow { - 4805
start_min: 22 * 60, - 4806
end_min: 7 * 60 - 4807
}) - 4808
); - 4809
assert_eq!(cfg.heartbeat.max_findings, 5); - 4810
} - 4811
- 4812
#[test] - 4813
fn heartbeat_interval_below_minimum_warns_and_clamps() { - 4814
let dir = tempfile::tempdir().unwrap(); - 4815
write_project_config(dir.path(), "[heartbeat]\ninterval_secs = 10\n"); - 4816
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4817
assert_eq!(cfg.heartbeat.interval_secs, 300); - 4818
assert!(cfg.warnings.iter().any(|w| w.contains("interval_secs"))); - 4819
} - 4820
- 4821
#[test] - 4822
fn heartbeat_zero_max_findings_clamps_with_warning() { - 4823
let dir = tempfile::tempdir().unwrap(); - 4824
write_project_config(dir.path(), "[heartbeat]\nmax_findings = 0\n"); - 4825
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4826
assert_eq!(cfg.heartbeat.max_findings, 1); - 4827
assert!(cfg.warnings.iter().any(|w| w.contains("max_findings"))); - 4828
} - 4829
- 4830
#[test] - 4831
fn heartbeat_bad_quiet_hours_warns_and_ignores() { - 4832
for bad in [ - 4833
"9am-5pm", - 4834
"25:00-07:00", - 4835
"07:00-07:00", - 4836
"22:00", - 4837
"22:61-07:00", - 4838
] { - 4839
let dir = tempfile::tempdir().unwrap(); - 4840
write_project_config( - 4841
dir.path(), - 4842
&format!("[heartbeat]\nquiet_hours = \"{bad}\"\n"), - 4843
); - 4844
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4845
assert_eq!(cfg.heartbeat.quiet_hours, None, "{bad} must be ignored"); - 4846
assert!( - 4847
cfg.warnings.iter().any(|w| w.contains("quiet_hours")), - 4848
"{bad} must warn: {:?}", - 4849
cfg.warnings - 4850
); - 4851
} - 4852
} - 4853
- 4854
#[test] - 4855
fn quiet_window_contains_boundary_matrix() { - 4856
// Wrapping window 22:00-07:00. - 4857
let night = QuietWindow { - 4858
start_min: 22 * 60, - 4859
end_min: 7 * 60, - 4860
}; - 4861
assert!(night.contains(22 * 60), "start bound inclusive"); - 4862
assert!(night.contains(23 * 60 + 59)); - 4863
assert!(night.contains(0)); - 4864
assert!(night.contains(6 * 60 + 59)); - 4865
assert!(!night.contains(7 * 60), "end bound exclusive"); - 4866
assert!(!night.contains(21 * 60 + 59)); - 4867
assert!(!night.contains(12 * 60)); - 4868
- 4869
// Plain window 13:00-14:00. - 4870
let lunch = QuietWindow { - 4871
start_min: 13 * 60, - 4872
end_min: 14 * 60, - 4873
}; - 4874
assert!(lunch.contains(13 * 60)); - 4875
assert!(lunch.contains(13 * 60 + 59)); - 4876
assert!(!lunch.contains(14 * 60), "end bound exclusive"); - 4877
assert!(!lunch.contains(12 * 60 + 59)); - 4878
} - 4879
- 4880
#[test] - 4881
fn unknown_heartbeat_key_warns() { - 4882
let dir = tempfile::tempdir().unwrap(); - 4883
write_project_config(dir.path(), "[heartbeat]\nbogus = 1\n"); - 4884
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4885
assert!( - 4886
cfg.warnings.iter().any(|w| w.contains("heartbeat.bogus")), - 4887
"{:?}",
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.