- 164
pub intent: IntentSettings, - 165
#[serde(default)] - 166
pub commitment: CommitmentSettings, - 167
#[serde(default)] - 168
pub automation: AutomationSettings, - 169
#[serde(default)] - 170
pub update: UpdateSettings, - 171
#[serde(default)] - 172
pub tools: ToolsSettings, - 173
#[serde(default)] - 174
pub heartbeat: HeartbeatSettings, - 175
#[serde(default)] - 176
pub feeds: FeedSettings, - 177
#[serde(default)] - 178
pub server: ServerSettings, - 179
#[serde(default)] - 180
pub plugins: PluginSettings, - 181
#[serde(default)] - 182
pub voice: Option<VoiceSettings>, - 183
/// Per-provider tuning knobs (docs/design/68-context-engine.md §8). - 184
/// NOT privileged: these only shape a provider's own request body. - 185
#[serde(default)] - 186
pub providers: ProvidersSettings, - 187
} - 188
- 189
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] - 190
#[serde(default)] - 191
pub struct VoiceSettings { - 192
pub enabled: bool, - 193
/// `gemini`, `openai` or `local` (`vak_voice::VoiceProvider`). Unset - 194
/// inherits from a wider layer; unset everywhere is a configuration gap - 195
/// the voice routes report, never an implicit vendor. - 196
pub provider: Option<String>, - 197
/// Hosted speech-to-text model, from the operator's discovered catalogue. - 198
pub transcription_model: Option<String>, - 199
/// Hosted text-to-speech model, from the operator's discovered catalogue. - 200
pub synthesis_model: Option<String>, - 201
pub max_session_secs: u64, - 202
pub max_concurrent: usize, - 203
pub max_audio_bytes: u64, - 204
/// Paid voice requests per rolling minute per server process, shared by - 205
/// transcription, synthesis and socket utterances. - 206
pub max_requests_per_minute: usize, - 207
/// Maximum synthesis input characters per request. - 208
pub max_text_chars: usize, - 209
} - 210
- 211
impl Default for VoiceSettings { - 212
fn default() -> Self { - 213
Self { - 214
enabled: false, - 215
provider: None, - 216
transcription_model: None, - 217
synthesis_model: None, - 218
max_session_secs: 900, - 219
max_concurrent: 2, - 220
max_audio_bytes: 16 * 1024 * 1024, - 221
max_requests_per_minute: 60, - 222
max_text_chars: 100_000, - 223
} - 224
} - 225
} - 226
- 227
impl VoiceSettings { - 228
pub fn validate(&self) -> Result<(), String> { - 229
for (name, value) in [ - 230
("voice.provider", self.provider.as_deref()), - 231
( - 232
"voice.transcription_model", - 233
self.transcription_model.as_deref(), - 234
), - 235
("voice.synthesis_model", self.synthesis_model.as_deref()), - 236
] { - 237
if let Some(value) = value - 238
&& (value.trim().is_empty() || value.chars().count() > 256) - 239
{ - 240
return Err(format!( - 241
"{name} must be non-empty and at most 256 characters" - 242
)); - 243
} - 244
} - 245
if self.max_session_secs == 0 || self.max_session_secs > 86_400 { - 246
return Err("voice.max_session_secs must be between 1 and 86400".into()); - 247
} - 248
if self.max_concurrent == 0 || self.max_concurrent > 64 { - 249
return Err("voice.max_concurrent must be between 1 and 64".into()); - 250
} - 251
if self.max_audio_bytes == 0 || self.max_audio_bytes > 256 * 1024 * 1024 { - 252
return Err("voice.max_audio_bytes must be between 1 and 268435456".into()); - 253
} - 254
if self.max_requests_per_minute == 0 || self.max_requests_per_minute > 10_000 { - 255
return Err("voice.max_requests_per_minute must be between 1 and 10000".into()); - 256
} - 257
if self.max_text_chars == 0 || self.max_text_chars > 10_000_000 { - 258
return Err("voice.max_text_chars must be between 1 and 10000000".into()); - 259
} - 260
Ok(()) - 261
} - 262
} - 263
- 264
/// How the HTTP surface is exposed (docs/design/48-web-client.md §4.2). - 265
/// - 266
/// PRIVILEGED, in full. Every key here either widens what the network can - 267
/// reach or relaxes a check that exists to stop it: `bind` decides which - 268
/// interface answers at all, `trusted_hosts` decides which `Host` headers - 269
/// are accepted (the DNS-rebinding defence), and `web.terminal` decides - 270
/// whether a remote caller can reach a real shell. A cloned repository - 271
/// setting any of these would be handing itself the machine. - 272
#[derive(Debug, Clone, Deserialize, Default)] - 273
#[serde(default)] - 274
pub struct ServerSettings { - 275
/// Interface to bind. Default `127.0.0.1`. - 276
pub bind: Option<String>, - 277
/// `Host` headers accepted besides loopback names. Exact matches only — - 278
/// a wildcard here is a rebinding hole with extra steps. - 279
pub trusted_hosts: Option<Vec<String>>, - 280
/// Public origin when behind a TLS-terminating proxy, e.g. - 281
/// `https://vak.example.com`. Enables `Secure` on the session cookie. - 282
pub public_url: Option<String>, - 283
/// Session cookie lifetime. Default 168 (one week); a public - 284
/// deployment should shorten it considerably. - 285
pub session_ttl_hours: Option<u64>, - 286
/// Hand a browser on THIS machine a session without asking for the - 287
/// token. Default true. See `ServerResolved::loopback_auto_login`. - 288
pub loopback_auto_login: Option<bool>, - 289
/// Directories the workspace picker may browse. Default: the user's - 290
/// home directory. - 291
pub workspace_roots: Option<Vec<String>>, - 292
#[serde(default)] - 293
pub web: WebSettings, - 294
/// Distributed event fabric configuration (vak-bus, docs/design/53). - 295
/// PRIVILEGED: a non-loopback NATS URL is network exposure, and the - 296
/// workspace secret is a credential. Stripped for untrusted projects. - 297
#[serde(default)] - 298
pub bus: BusConfig, - 299
} - 300
- 301
/// Distributed event bus configuration (docs/design/53-distributed-bus.md). - 302
/// Lives inside `[server]` because a NATS endpoint is network exposure - 303
/// (rule 34) and the workspace secret is a credential (rule 8). - 304
#[derive(Debug, Clone, Deserialize, Default)] - 305
#[serde(default)] - 306
pub struct BusConfig { - 307
/// NATS server URL. Empty/unset = local InMemoryBus only. - 308
pub nats_url: Option<String>, - 309
/// Name of the env var holding the workspace encryption secret. - 310
/// The secret itself is never stored in config — only the env var name. - 311
pub workspace_secret_env: Option<String>, - 312
} - 313
- 314
/// The NATS credentials JWT, a secret: kept in the secrets chain under this - 315
/// name (project scope when set through `/config/bus`), never in TOML. - 316
pub const BUS_NATS_JWT_VAR: &str = "VAK_BUS_NATS_CREDENTIALS_JWT"; - 317
/// The NATS nkey seed, a secret, kept like [`BUS_NATS_JWT_VAR`]. - 318
pub const BUS_NATS_NKEY_SEED_VAR: &str = "VAK_BUS_NATS_NKEY_SEED"; - 319
- 320
#[derive(Debug, Clone, Deserialize, Default)] - 321
#[serde(default)] - 322
pub struct WebSettings { - 323
/// Serve a real PTY over the web client. Default false: a shell over - 324
/// HTTP is remote code execution, and unlike every other effect in this - 325
/// product it is not mediated by the permission engine. - 326
pub terminal: Option<bool>, - 327
/// Refuse the terminal to non-loopback hosts even when it is enabled. - 328
/// Default true. - 329
pub terminal_requires_loopback: Option<bool>, - 330
} - 331
- 332
/// Resolved HTTP exposure settings. - 333
#[derive(Debug, Clone)] - 334
pub struct ServerResolved { - 335
pub bind: String, - 336
/// Whether a loopback browser is signed in automatically. - 337
/// - 338
/// The token exists to stop OTHER local processes driving the agent. - 339
/// Against a process running as *you* it was never much of a boundary — - 340
/// that process can read the same credential store the token is pinned - 341
/// in. What it - 342
/// does protect is a machine with other human users on it, who can reach - 343
/// 127.0.0.1 but cannot read your files. - 344
/// - 345
/// So: on by default, because a single-user laptop is the overwhelming - 346
/// case and making someone hunt for a token to reach their own machine - 347
/// is friction with nothing on the other side of it. Turn it off on a - 348
/// shared box. It NEVER applies beyond loopback — a remote deployment - 349
/// always asks, whatever this says. - 350
pub loopback_auto_login: bool, - 351
pub trusted_hosts: Vec<String>, - 352
pub public_url: Option<String>, - 353
pub session_ttl_hours: u64, - 354
pub workspace_roots: Vec<std::path::PathBuf>, - 355
pub web_terminal: bool, - 356
pub web_terminal_requires_loopback: bool, - 357
pub bus: BusResolved, - 358
} - 359
- 360
/// Resolved distributed event bus configuration. - 361
#[derive(Debug, Clone, Default)] - 362
pub struct BusResolved { - 363
/// NATS server URL. None = local InMemoryBus only. - 364
pub nats_url: Option<String>, - 365
/// Resolved workspace encryption key (read from the env var named in - 366
/// `BusConfig.workspace_secret_env`). Never the env var name itself. - 367
pub workspace_secret: Option<Vec<u8>>, - 368
} - 369
- 370
impl ServerResolved { - 371
/// Whether `bind` reaches beyond this machine's loopback interface. - 372
pub fn binds_publicly(&self) -> bool { - 373
!matches!(self.bind.as_str(), "127.0.0.1" | "::1" | "localhost") - 374
} - 375
- 376
/// Cookies may only carry `Secure` when the browser actually reached us - 377
/// over TLS; setting it on plain http makes the browser drop the cookie - 378
/// and the session silently never persists. - 379
pub fn cookie_is_secure(&self) -> bool { - 380
self.public_url - 381
.as_deref() - 382
.is_some_and(|url| url.starts_with("https://")) - 383
} - 384
} - 385
- 386
/// Cross-session recall (docs/design/23-memory.md). Read-only and - 387
/// workspace-scoped, so unlike [gateway] this section is NOT privileged. - 388
#[derive(Debug, Clone, Deserialize, Default)] - 389
#[serde(default)] - 390
pub struct MemorySettings { - 391
/// Expose the `session_search` tool to agent runs. Default true. - 392
pub search_enabled: Option<bool>, - 393
/// `remember` tool: append durable per-workspace notes. Default true. - 394
pub write_enabled: Option<bool>, - 395
/// `propose_skill` tool: queue drafts for human promotion. Default true. - 396
pub skill_proposals: Option<bool>, - 397
/// Post-run reflection: an auxiliary model call proposes durable notes / - 398
/// skill drafts after clean completions, deduped against existing - 399
/// memory. Costs one extra request per run — default false. - 400
pub reflection: Option<bool>, - 401
} - 402
- 403
/// Execution backend selection (docs/design/25-docker-sandbox.md). - 404
/// Privileged: an untrusted repo must not pick the image its commands run - 405
/// in. "auto" keeps platform defaults (Seatbelt on macOS, Landlock on - 406
/// Linux); "docker" runs bash in a throwaway no-network container. - 407
#[derive(Debug, Clone, Deserialize, Default)] - 408
#[serde(default)] - 409
pub struct SandboxSettings { - 410
pub backend: Option<String>, - 411
/// Container image for the docker backend (default alpine:3.20). - 412
pub image: Option<String>, - 413
} - 414
- 415
/// Always-on gateway surfaces (docs/design/22-gateway.md). Privileged: - 416
/// stripped from untrusted project config because enabling it allows - 417
/// remote execution. - 418
#[derive(Debug, Clone, Deserialize, Default)] - 419
#[serde(default)] - 420
pub struct GatewaySettings { - 421
pub enabled: Option<bool>, - 422
/// "deny" (default) auto-denies approval gates on unattended turns. - 423
/// "forward" routes them to the `approver` target for a yes/no reply. - 424
pub approvals: Option<String>, - 425
/// Routing target ("<surface>:<chat>") that answers forwarded approval - 426
/// gates. Required when approvals = "forward". - 427
pub approver: Option<String>, - 428
/// How long a forwarded gate waits for a reply before failing closed - 429
/// (default 300, minimum 5). - 430
pub approval_timeout_secs: Option<u64>, - 431
#[serde(default)] - 432
pub outbound: OutboundSettings, - 433
/// Inbound rate limiting (0a-06). None uses sensible defaults. - 434
pub rate_limit: Option<RateLimitSettings>, - 435
/// Allowed inbound chat keys: `["telegram:12345", "log:ops"]`. - 436
/// Empty list fails closed (0c-02): every inbound chat is rejected - 437
/// until either this is populated or `chat_allowlist_open` is set. - 438
#[serde(default)] - 439
pub chat_allowlist: Vec<String>, - 440
/// Explicit opt-out of the allowlist: any chat may reach the gateway. - 441
/// Only takes effect when `chat_allowlist` is empty; ignored otherwise. - 442
/// Default false — an operator must opt in to open access. - 443
pub chat_allowlist_open: Option<bool>, - 444
/// Process-wide cap on concurrently pooled per-workspace `Core` - 445
/// instances (docs/design/34-channel-onboarding.md Phase 2). Default 8. - 446
pub core_pool_max: Option<usize>, - 447
/// Idle duration (seconds) after which a pooled non-default-workspace - 448
/// `Core` is evicted. Default 1800 (30 minutes). - 449
pub core_pool_idle_secs: Option<u64>, - 450
/// Days a `pending` allowlist entry may sit unreviewed before - 451
/// `vak doctor` flags it and `--repair` auto-denies it - 452
/// (docs/design/34-channel-onboarding.md). Default 7. - 453
pub pending_expiry_days: Option<u64>, - 454
} - 455
- 456
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] - 457
pub struct RateLimitSettings { - 458
/// Max requests per window for `POST /gateway/inbound`. - 459
pub inbound_per_min: Option<u32>, - 460
/// Max requests per window for `POST /sessions`. - 461
pub sessions_per_min: Option<u32>, - 462
/// Max requests per window for `POST /sessions/{id}/run`. - 463
pub runs_per_min: Option<u32>, - 464
/// Max requests per window for all other POST endpoints. - 465
pub other_post_per_min: Option<u32>, - 466
/// Window duration in seconds. - 467
pub window_secs: Option<u64>, - 468
} - 469
- 470
#[derive(Debug, Clone, Deserialize, Default)] - 471
#[serde(default)] - 472
pub struct OutboundSettings { - 473
/// Named webhook delivery targets: `[gateway.outbound.webhooks.<name>]`. - 474
/// Target strings use `webhook:<name>`. - 475
pub webhooks: std::collections::BTreeMap<String, WebhookTarget>, - 476
} - 477
- 478
#[derive(Debug, Clone, Deserialize)] - 479
pub struct WebhookTarget { - 480
pub url: String, - 481
/// Name of an env var holding a bearer token attached to each - 482
/// delivery. The value is resolved at delivery time and never stored - 483
/// in config; a configured-but-missing token fails the delivery - 484
/// closed instead of posting unauthenticated. - 485
pub token_env: Option<String>, - 486
} - 487
- 488
#[derive(Debug, Clone, Deserialize, Default)] - 489
#[serde(default)] - 490
pub struct UiSettings { - 491
pub theme: Option<String>, - 492
pub bell: Option<bool>, - 493
/// Keymap overrides: `"Ctrl-P" = "command-palette"` or - 494
/// `"running|Tab" = "queue"`. - 495
pub keymap: std::collections::BTreeMap<String, String>, - 496
/// `"emacs"` (default) or `"vim"`. - 497
pub composer: Option<String>, - 498
/// Opt-in OSC52 clipboard copy. Never automatic: an explicit user - 499
/// action (Alt-Y / `/copy`) is required even when enabled. - 500
pub osc52: Option<bool>, - 501
#[serde(default)] - 502
pub accessibility: Option<AccessibilitySettings>, - 503
/// Custom theme definitions: `[ui.themes.<name>]` with color keys - 504
/// (`accent`, `dim`, ...) mapped to `#rrggbb` or named colors. Held as - 505
/// raw TOML values so a stray non-string entry warns instead of making - 506
/// the whole config unparseable. - 507
#[serde(default)] - 508
pub themes: std::collections::BTreeMap<String, std::collections::BTreeMap<String, toml::Value>>, - 509
} - 510
- 511
#[derive(Debug, Clone, Deserialize, Default)] - 512
pub struct AccessibilitySettings { - 513
pub plain: Option<bool>, - 514
pub reduced_motion: Option<bool>, - 515
pub screen_reader: Option<bool>, - 516
} - 517
- 518
#[derive(Debug, Clone, Deserialize, Default)] - 519
pub struct StopPolicySettings { - 520
pub enabled: Option<bool>, - 521
pub marker_gate: Option<bool>, - 522
pub verify_gate: Option<bool>, - 523
pub max_blocks: Option<u32>, - 524
} - 525
- 526
/// Spend admission (docs/design/15-reliability.md). Absent prices are UNKNOWN: - 527
/// unpriced models bypass USD math rather than guessing at zero. - 528
#[derive(Debug, Clone, Deserialize, Default)] - 529
#[serde(default)] - 530
pub struct GoalSettings { - 531
pub handoff_reset: Option<bool>, - 532
pub max_audit_blocks: Option<u32>, - 533
} - 534
- 535
#[derive(Debug, Clone, Deserialize, Default)] - 536
#[serde(default)] - 537
pub struct WorkSettings { - 538
pub enabled: Option<bool>, - 539
pub default_mode: Option<String>, - 540
pub max_items: Option<usize>, - 541
pub max_revisions: Option<u32>, - 542
pub max_parallel: Option<usize>, - 543
pub confirmation: Option<String>, - 544
} - 545
- 546
#[derive(Debug, Clone, Deserialize, Default)] - 547
#[serde(default)] - 548
pub struct FinopsSettings { - 549
pub max_run_usd: Option<f64>, - 550
pub max_day_usd: Option<f64>, - 551
/// Exact model id → (input USD/MTok, output USD/MTok). Overrides the - 552
/// built-in heuristic table; estimates stay labeled as estimates. - 553
pub price_overrides: std::collections::BTreeMap<String, PriceEntry>, - 554
} - 555
- 556
/// Capacity-probe cost control (docs/design/68-context-engine.md §1 "Cost - 557
/// control for hosted models"). Local models are always probed in full - 558
/// regardless of this setting; it governs hosted models only. - 559
#[derive(Debug, Clone, Deserialize, Default)] - 560
#[serde(default)] - 561
pub struct ProbeSettings { - 562
/// "none" (default) starts a hosted profile's instruction horizon at - 563
/// its declared window with low confidence, tightened only by - 564
/// feedback; "full" opts in to running the horizon ladder against - 565
/// hosted models too. - 566
pub hosted: Option<String>, - 567
} - 568
- 569
/// Resolved capacity-probe policy. - 570
#[derive(Debug, Clone, PartialEq, Eq)] - 571
pub struct ProbeResolved { - 572
pub hosted: String, - 573
} - 574
- 575
/// Per-provider tuning sections. One field per provider that has knobs - 576
/// beyond credentials/base-url; a provider with nothing to tune has none. - 577
#[derive(Debug, Clone, Deserialize, Default)] - 578
#[serde(default)] - 579
pub struct ProvidersSettings { - 580
pub ollama: OllamaSettings, - 581
pub anthropic: AnthropicSettings, - 582
} - 583
- 584
/// Anthropic provider tuning (docs/design/68-context-engine.md §11 - 585
/// "Anthropic" row, the API's "Fast Mode" quick reference). - 586
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] - 587
#[serde(default)] - 588
pub struct AnthropicSettings { - 589
/// Opt-in fast-mode research preview: `speed: "fast"` plus the - 590
/// `anthropic-beta: fast-mode-2026-02-01` header, sent only for a model - 591
/// whose discovered capabilities confirm support. Premium pricing and a - 592
/// separate rate-limit bucket, so this defaults to off (`None` resolves - 593
/// to `false`). - 594
pub fast_mode: Option<bool>, - 595
} - 596
- 597
/// Native Ollama provider tuning (docs/design/68-context-engine.md §8): the - 598
/// OpenAI-compatible path silently ignores both of these, so the native - 599
/// `/api/chat` adapter needs them threaded from config. - 600
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] - 601
#[serde(default)] - 602
pub struct OllamaSettings { - 603
/// Go-style duration string ("10m", "24h", "0"). Sent on every request - 604
/// so the runner does not evict the model under the default 5-minute - 605
/// idle unload. - 606
pub keep_alive: Option<String>, - 607
/// `options.num_ctx`. Must be >= 1024 when set; omitted entirely when - 608
/// `None` so the server's own modelfile default applies. - 609
pub num_ctx: Option<u64>, - 610
} - 611
- 612
impl OllamaSettings { - 613
pub fn validate(&self) -> Result<(), String> { - 614
if let Some(ka) = &self.keep_alive - 615
&& !is_go_duration(ka) - 616
{ - 617
return Err(format!( - 618
"providers.ollama.keep_alive '{ka}' is not a valid Go-style \ - 619
duration (e.g. \"10m\", \"24h\", \"0\")" - 620
)); - 621
} - 622
if let Some(n) = self.num_ctx - 623
&& n < 1024 - 624
{ - 625
return Err("providers.ollama.num_ctx must be >= 1024 when set".into()); - 626
} - 627
Ok(()) - 628
} - 629
} - 630
- 631
/// Minimal Go `time.ParseDuration` shape check: `"0"`, or one or more - 632
/// `<number><unit>` pairs with no separators, units restricted to the ones - 633
/// Ollama's own duration parsing accepts. - 634
fn is_go_duration(s: &str) -> bool { - 635
let s = s.trim(); - 636
if s == "0" { - 637
return true; - 638
} - 639
let mut chars = s.chars().peekable(); - 640
let mut matched_any = false; - 641
while chars.peek().is_some() { - 642
let mut num = String::new(); - 643
while let Some(&c) = chars.peek() { - 644
if c.is_ascii_digit() || c == '.' { - 645
num.push(c); - 646
chars.next(); - 647
} else { - 648
break; - 649
} - 650
} - 651
if num.is_empty() || num == "." { - 652
return false; - 653
} - 654
let mut unit = String::new(); - 655
while let Some(&c) = chars.peek() { - 656
if c.is_alphabetic() || c == '\u{00b5}' { - 657
unit.push(c); - 658
chars.next(); - 659
} else { - 660
break; - 661
} - 662
} - 663
if !matches!( - 664
unit.as_str(), - 665
"ns" | "us" | "\u{00b5}s" | "ms" | "s" | "m" | "h" - 666
) { - 667
return false; - 668
} - 669
matched_any = true; - 670
} - 671
matched_any - 672
} - 673
- 674
/// Frozen-ladder routing preferences (docs/design/15-reliability.md + Phase R). - 675
/// NOT privileged: choosing how to order discovered candidates grants no - 676
/// execution power. - 677
#[derive(Debug, Clone, Deserialize, Default)] - 678
#[serde(default)] - 679
pub struct RouteSettings { - 680
/// "auto" (default) derives utility/balanced/quality-critical from - 681
/// request demand; explicit "utility" | "balanced" | - 682
/// "quality-critical" overrides the derivation. - 683
pub objective: Option<String>, - 684
/// Explicit cross-model fallback allowlist. Model ids here become - 685
/// candidate legs WHEN warm discovery shows a configured key can - 686
/// reach them; empty keeps the legacy same-model-only ladder. - 687
pub fallback_models: Vec<String>, - 688
/// Total ladder length cap INCLUDING the primary leg (default 4). - 689
pub max_fallbacks: Option<usize>, - 690
/// Caller-declared frontier-tier model-id substrings promoted under - 691
/// balanced/quality-critical objectives. Routing knowledge stays - 692
/// operator-supplied, never baked into source (invariant 9). - 693
pub quality_hints: Vec<String>, - 694
/// Model-id substrings that mark a leg as able to serve non-text input - 695
/// (images, audio, …). A vision turn is served only by matching legs; - 696
/// with no hints declared every leg is assumed capable, because a - 697
/// restriction the operator did not state is not ours to invent - 698
/// (docs/design/47-commitment-kernel.md, invariant 10). - 699
pub modality_hints: Vec<String>, - 700
} - 701
- 702
/// Intent kernel (docs/design/47-commitment-kernel.md). - 703
/// - 704
/// PARTLY PRIVILEGED. Most of this section only ever narrows what a turn may - 705
/// do, and a repository choosing to give itself fewer tools is harmless. Two - 706
/// keys are different and are stripped for an untrusted project by - 707
/// `load_with_trust`: - 708
/// - 709
/// * `autonomy` — `delegated` and `autonomous` suppress approval gates the - 710
/// agent would otherwise raise. That is execution power, and a cloned - 711
/// repository must not be able to grant it to itself. - 712
/// * `escalate = "cloud"` — spends the user's credentials on a classification - 713
/// dispatch before the run they actually asked for. - 714
#[derive(Debug, Clone, Deserialize, Default)] - 715
#[serde(default)] - 716
pub struct IntentSettings { - 717
/// Master switch. `false` resolves every turn to the general engagement, - 718
/// which is vak's behaviour before the kernel existed. Default true. - 719
pub enabled: Option<bool>, - 720
/// Confidence at or above which a reading may narrow capability. - 721
pub accept_confidence: Option<f64>, - 722
/// Confidence at or above which a reading may raise risk posture but not - 723
/// remove tools. - 724
pub provisional_confidence: Option<f64>, - 725
/// Allow progressive disclosure of the capability packet. Default true. - 726
pub slice_capabilities: Option<bool>, - 727
/// Allow stakes to raise the approval floor. Default true. - 728
pub posture: Option<bool>, - 729
/// How far the cascade may escalate: "none" | "local" | "cloud". - 730
pub escalate: Option<String>, - 731
/// Model for the classification tier: `model`, or `provider/model`. - 732
/// Local escalation runs it on `ollama`; cloud on the effective - 733
/// provider unless a provider is named. Empty uses the effective route. - 734
pub classify_model: Option<String>, - 735
/// Hard ceiling on one classification dispatch. - 736
pub max_classify_usd: Option<f64>, - 737
/// Watchdog on one classification dispatch, in seconds (default 10). - 738
/// A classifier that overruns it is abandoned and the free-tier reading - 739
/// stands; the run never waits on it. - 740
pub classify_timeout_secs: Option<u64>, - 741
/// Standing delegation: "manual" | "assisted" | "delegated" | "autonomous". - 742
pub autonomy: Option<String>, - 743
pub evidence_max_age_secs: Option<i64>, - 744
} - 745
- 746
/// Durable commitments (docs/design/47-commitment-kernel.md). NOT privileged: - 747
/// every key here bounds long-running work rather than enabling it. - 748
#[derive(Debug, Clone, Deserialize, Default)] - 749
#[serde(default)] - 750
pub struct CommitmentSettings { - 751
/// Open durable commitments for session-or-longer work. Default true. - 752
pub enabled: Option<bool>, - 753
/// Lifetime spend cap per commitment. None inherits the FinOps caps only. - 754
pub lifetime_budget_usd: Option<f64>, - 755
/// Consecutive stalled episodes before the stall breaker trips. - 756
pub stall_limit: Option<u32>, - 757
/// Surface a commitment for human review after this long untouched. - 758
pub review_every_hours: Option<u32>, - 759
/// Default relevance window. A commitment past it closes `expired` - 760
/// explicitly rather than lingering. - 761
pub default_ttl_days: Option<u32>, - 762
} - 763
- 764
/// Scheduled-task behavior (docs/design/29-personal-os.md P2). NOT - 765
/// privileged: catch-up only widens when an already-configured task may run. - 766
#[derive(Debug, Clone, Deserialize, Default)] - 767
#[serde(default)] - 768
pub struct AutomationSettings { - 769
/// Run tasks that missed their schedule while the app was shut down. - 770
/// Default true. - 771
pub catch_up_missed: Option<bool>, - 772
} - 773
- 774
/// Opt-in update awareness (docs/design/29-personal-os.md P3). A `None` - 775
/// url disables update checks entirely; checks never auto-install. - 776
#[derive(Debug, Clone, Deserialize, Default)] - 777
#[serde(default)] - 778
pub struct UpdateSettings { - 779
/// Version-manifest URL polled for update banners. Default: none - 780
/// (update checks fully disabled). - 781
pub url: Option<String>, - 782
/// Hours between update checks (default 24). - 783
pub interval_hours: Option<u64>, - 784
} - 785
- 786
/// Master switches for optional built-in tool registration. - 787
#[derive(Debug, Clone, Deserialize, Default)] - 788
#[serde(default)] - 789
pub struct ToolsSettings { - 790
/// Register the bounded webfetch tool. Default true; network access is - 791
/// still permission-classified per request. - 792
pub web_fetch: Option<bool>, - 793
/// Register the headless-browser DOM render tool (`browse`). Default - 794
/// true; requires a locally installed Chromium-family browser and is - 795
/// still permission-classified per request. - 796
pub browse: Option<bool>, - 797
} - 798
- 799
/// Proactive heartbeat (docs/design/29-personal-os.md P7). NOT privileged: - 800
/// it spends this server's own configured credentials on a bounded review - 801
/// turn, never grants new execution power. - 802
#[derive(Debug, Clone, Deserialize, Default)] - 803
#[serde(default)] - 804
pub struct HeartbeatSettings { - 805
pub enabled: Option<bool>, - 806
/// Seconds between review turns (default 1800, minimum 300). - 807
pub interval_secs: Option<u64>, - 808
/// Model pin ("model" or "provider/model"); default keeps the - 809
/// provider's current model. - 810
pub model: Option<String>, - 811
/// Local-time quiet window "HH:MM-HH:MM" during which cycles skip. - 812
/// Wraps midnight ("22:00-07:00"). - 813
pub quiet_hours: Option<String>, - 814
/// Maximum findings reported per beat (default 3). - 815
pub max_findings: Option<usize>, - 816
} - 817
- 818
/// Feed pipeline settings. Read-only and workspace-scoped. - 819
#[derive(Debug, Clone, Deserialize, Default)] - 820
#[serde(default)] - 821
pub struct FeedSettings { - 822
/// Enable the feed pipeline. Default false. - 823
pub enabled: Option<bool>, - 824
/// Path to feeds.toml config file. None = auto-detect. - 825
pub config_path: Option<String>, - 826
/// Path to the DuckDB database file. None = auto-detect. - 827
pub db_path: Option<String>, - 828
/// Default check interval for sources (e.g. "30m", "1h"). - 829
pub default_check_interval: Option<String>, - 830
/// Maximum items to keep per feed. - 831
pub max_items_per_feed: Option<u32>, - 832
/// Days to keep dedup hashes. - 833
pub dedup_window_days: Option<u32>, - 834
} - 835
- 836
/// Resolved feed pipeline settings. - 837
#[derive(Debug, Clone)] - 838
pub struct FeedResolved { - 839
pub enabled: bool, - 840
pub config_path: Option<String>, - 841
pub db_path: Option<String>, - 842
pub default_check_interval: String, - 843
pub max_items_per_feed: u32, - 844
pub dedup_window_days: u32, - 845
} - 846
- 847
#[derive(Debug, Clone, Deserialize, PartialEq)] - 848
pub struct PriceEntry { - 849
pub input: f64, - 850
pub output: f64, - 851
} - 852
- 853
#[derive(Debug, Clone, Deserialize, Default)] - 854
pub struct McpConfig { - 855
#[serde(default)] - 856
pub servers: std::collections::BTreeMap<String, McpServerConfig>, - 857
} - 858
- 859
#[derive(Debug, Clone, Serialize, Deserialize)] - 860
pub struct McpServerConfig { - 861
pub command: String, - 862
#[serde(default)] - 863
pub args: Vec<String>, - 864
#[serde(default)] - 865
pub env: std::collections::BTreeMap<String, String>, - 866
/// Allow outbound network for this MCP server. Privileged (mcp.servers - 867
/// is stripped from untrusted projects). - 868
#[serde(default)] - 869
pub network: bool, - 870
/// What this server is for, in its own words: `serves = ["live-data"]`. - 871
/// - 872
/// Optional, and deliberately so. It decides whether a call to this - 873
/// server counts as retrieval that an answer must be grounded in; an - 874
/// empty list means *undeclared*, and the server inherits the `mcp` - 875
/// broker's web/live-data claim. It never hides the server from a turn. - 876
/// The alternative, guessing a domain from the server's tool names, - 877
/// would put a keyword table back in the harness and reintroduce the - 878
/// coupling this field exists to remove. - 879
/// - 880
/// Skipped when empty so the config file and the management API keep - 881
/// exactly the shape they had before this field existed — a server that - 882
/// declares nothing should look no different from one written last year. - 883
#[serde(default, skip_serializing_if = "Vec::is_empty")] - 884
pub serves: Vec<String>, - 885
} - 886
- 887
/// Project-layer switches for severing one inherited capability category. - 888
/// Missing means inherit. User-layer values are accepted but only become - 889
/// meaningful when a narrower layer is merged over them. - 890
#[derive(Debug, Clone, Deserialize, Default)] - 891
#[serde(default)] - 892
pub struct CapabilityInheritanceSettings { - 893
pub inherit_mcp: Option<bool>, - 894
pub inherit_hooks: Option<bool>, - 895
pub inherit_skills: Option<bool>, - 896
pub inherit_commands: Option<bool>, - 897
pub inherit_plugins: Option<bool>, - 898
} - 899
- 900
#[derive(Debug, Clone)] - 901
pub struct CapabilityInheritanceResolved { - 902
pub inherit_mcp: bool, - 903
pub inherit_hooks: bool, - 904
pub inherit_skills: bool, - 905
pub inherit_commands: bool, - 906
pub inherit_plugins: bool, - 907
} - 908
- 909
/// Global and workspace settings governing capability plugins. - 910
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] - 911
#[serde(default)] - 912
pub struct PluginSettings { - 913
/// Explicitly enabled plugin names. - 914
pub enabled: Vec<String>, - 915
/// Explicitly disabled plugin names. - 916
pub disabled: Vec<String>, - 917
/// Optional allowlist of permitted plugin names. If specified, only matching plugins may be enabled. - 918
pub allow: Option<Vec<String>>, - 919
/// Denylist of forbidden plugin names. Deny always takes precedence over allow. - 920
pub deny: Vec<String>, - 921
/// Plugins permitted outbound network access. Privileged. - 922
pub network_allow: Option<Vec<String>>, - 923
/// Plugins forbidden outbound network access. Precedence: an entry - 924
/// here always beats `network_allow` (channel `plugins_network_deny` - 925
/// layers on top of both and can only take egress away). - 926
pub network_deny: Vec<String>, - 927
} - 928
- 929
/// Resolved plugin policy across global and workspace layers. - 930
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] - 931
pub struct PluginResolved { - 932
pub enabled: Vec<String>, - 933
pub disabled: Vec<String>, - 934
pub allow: Option<Vec<String>>, - 935
pub deny: Vec<String>, - 936
pub network_allow: Option<Vec<String>>, - 937
pub network_deny: Vec<String>, - 938
} - 939
- 940
impl PluginResolved { - 941
pub fn is_enabled(&self, name: &str) -> bool { - 942
// Deny always wins - 943
if self.deny.iter().any(|d| d == name || d == "*") { - 944
return false; - 945
} - 946
if self.disabled.iter().any(|d| d == name) { - 947
return false; - 948
} - 949
if let Some(allow) = &self.allow { - 950
return allow.iter().any(|a| a == name || a == "*"); - 951
} - 952
if !self.enabled.is_empty() { - 953
return self.enabled.iter().any(|e| e == name || e == "*"); - 954
} - 955
true - 956
} - 957
- 958
pub fn is_network_allowed(&self, name: &str) -> bool { - 959
if !self.is_enabled(name) { - 960
return false; - 961
} - 962
if self.network_deny.iter().any(|d| d == name || d == "*") { - 963
return false; - 964
} - 965
if let Some(allow) = &self.network_allow { - 966
return allow.iter().any(|a| a == name || a == "*"); - 967
} - 968
false - 969
} - 970
} - 971
- 972
/// Restrictive capability overlay for a gateway channel. `None` means inherit - 973
/// the workspace policy; `Some([])` means deny everything in that category. - 974
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] - 975
#[serde(default)] - 976
pub struct ChannelPolicy { - 977
pub tools_allow: Option<Vec<String>>, - 978
pub tools_deny: Vec<String>, - 979
pub mcp_allow: Option<Vec<String>>, - 980
pub mcp_deny: Vec<String>, - 981
pub skills_allow: Option<Vec<String>>, - 982
pub skills_deny: Vec<String>, - 983
pub hooks_allow: Option<Vec<String>>, - 984
pub hooks_deny: Vec<String>, - 985
/// Server-name patterns (matched the same way as `mcp_allow`/`mcp_deny`) - 986
/// for which this channel forces outbound network off, even when the - 987
/// server's own `McpServerConfig.network` is `true`. Restrictive only — - 988
/// there is deliberately no matching "network_allow": a channel can - 989
/// only take network access away from a server it can already reach, - 990
/// never grant it to one the server config itself denies. - 991
pub mcp_network_deny: Vec<String>, - 992
/// Plugin-name patterns for which this channel forces outbound network off. - 993
pub plugins_network_deny: Vec<String>, - 994
/// Optional allowlist of plugin names permitted on this channel. - 995
pub plugins_allow: Option<Vec<String>>, - 996
/// Denylist of plugin names forbidden on this channel. - 997
pub plugins_deny: Vec<String>, - 998
/// Autonomy ceiling for this channel (docs/design/47-commitment-kernel.md). - 999
/// - 1000
/// **Restrictive only**, like everything else on this type: a channel may - 1001
/// cap delegation below what the workspace granted, never raise it. That - 1002
/// asymmetry is the point — a Telegram chat should be able to say "propose - 1003
/// only, in here", and must never be able to say "act freely" on a - 1004
/// workspace whose operator did not. - 1005
/// - 1006
/// `None` inherits. Values: `manual` | `assisted` | `delegated` | - 1007
/// `autonomous`. - 1008
pub autonomy_ceiling: Option<String>, - 1009
} - 1010
- 1011
/// Rank an autonomy name, mirroring `vak_intent::Autonomy::rank`. - 1012
/// - 1013
/// Duplicated rather than imported because `vak-config` deliberately does not - 1014
/// depend on the intent kernel; the ranking is asserted equal by a test in - 1015
/// `vak-core`, which sees both. - 1016
fn autonomy_rank(name: &str) -> u8 { - 1017
match name { - 1018
"manual" => 0, - 1019
"assisted" => 1, - 1020
"delegated" => 2, - 1021
"autonomous" => 3, - 1022
_ => 1, - 1023
} - 1024
} - 1025
- 1026
impl ChannelPolicy { - 1027
/// The least-delegated of two autonomy ceilings. `None` on either side - 1028
/// means "says nothing", not "allows everything". - 1029
pub fn cap_autonomy(lower: Option<&str>, higher: Option<&str>) -> Option<String> { - 1030
match (lower, higher) { - 1031
(None, None) => None, - 1032
(Some(one), None) | (None, Some(one)) => Some(one.to_string()), - 1033
(Some(a), Some(b)) => Some( - 1034
if autonomy_rank(b) < autonomy_rank(a) { - 1035
b - 1036
} else { - 1037
a - 1038
} - 1039
.to_string(), - 1040
), - 1041
} - 1042
} - 1043
- 1044
/// Fold a lower tier (e.g. bot) and a higher tier (e.g. chat) into the - 1045
/// single effective policy applied at dispatch. Restrictive-only: an - 1046
/// `_allow` list from the higher tier wins outright when present (it is - 1047
/// itself already capped against whatever it's allowed to name), a - 1048
/// missing `_allow` falls back to the lower tier's, and `_deny` lists - 1049
/// concatenate across tiers since denies only ever remove, never add, - 1050
/// access. `lower` is the more permissive default (bot), `higher` is - 1051
/// the more specific override (chat). - 1052
pub fn merge(lower: &ChannelPolicy, higher: &ChannelPolicy) -> ChannelPolicy { - 1053
fn merge_allow( - 1054
lower: &Option<Vec<String>>, - 1055
higher: &Option<Vec<String>>, - 1056
) -> Option<Vec<String>> { - 1057
higher.clone().or_else(|| lower.clone()) - 1058
} - 1059
fn merge_deny(lower: &[String], higher: &[String]) -> Vec<String> { - 1060
let mut out = lower.to_vec(); - 1061
for item in higher { - 1062
if !out.contains(item) { - 1063
out.push(item.clone()); - 1064
} - 1065
} - 1066
out - 1067
} - 1068
ChannelPolicy { - 1069
tools_allow: merge_allow(&lower.tools_allow, &higher.tools_allow), - 1070
tools_deny: merge_deny(&lower.tools_deny, &higher.tools_deny), - 1071
mcp_allow: merge_allow(&lower.mcp_allow, &higher.mcp_allow), - 1072
mcp_deny: merge_deny(&lower.mcp_deny, &higher.mcp_deny), - 1073
skills_allow: merge_allow(&lower.skills_allow, &higher.skills_allow), - 1074
skills_deny: merge_deny(&lower.skills_deny, &higher.skills_deny), - 1075
hooks_allow: merge_allow(&lower.hooks_allow, &higher.hooks_allow), - 1076
hooks_deny: merge_deny(&lower.hooks_deny, &higher.hooks_deny), - 1077
mcp_network_deny: merge_deny(&lower.mcp_network_deny, &higher.mcp_network_deny), - 1078
plugins_network_deny: merge_deny( - 1079
&lower.plugins_network_deny, - 1080
&higher.plugins_network_deny, - 1081
), - 1082
plugins_allow: merge_allow(&lower.plugins_allow, &higher.plugins_allow), - 1083
plugins_deny: merge_deny(&lower.plugins_deny, &higher.plugins_deny), - 1084
autonomy_ceiling: Self::cap_autonomy( - 1085
lower.autonomy_ceiling.as_deref(), - 1086
higher.autonomy_ceiling.as_deref(), - 1087
), - 1088
} - 1089
} - 1090
} - 1091
- 1092
/// Optional spoken voice + persona for a bot/chat, resolved through the - 1093
/// same bot→chat inheritance idiom as `route`/`permission_mode` (see - 1094
/// `GatewayState::core_for_entry` in vak-server::gateway). `None` on a - 1095
/// field means "no override for that piece"; the whole `VoiceConfig` being - 1096
/// `None` on the entity means "inherit the parent tier's voice entirely". - 1097
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] - 1098
pub struct VoiceConfig { - 1099
/// Provider override for voice operations at this scope. - 1100
#[serde(default, skip_serializing_if = "Option::is_none")] - 1101
pub provider: Option<String>, - 1102
/// Live API prebuilt voice name, e.g. "Kore", "Puck", "Zephyr". - 1103
#[serde(default, skip_serializing_if = "Option::is_none")] - 1104
pub voice_name: Option<String>, - 1105
/// Provider model override for transcription at this scope. - 1106
#[serde(default, skip_serializing_if = "Option::is_none")] - 1107
pub transcription_model: Option<String>, - 1108
/// Provider model override for synthesis at this scope. - 1109
#[serde(default, skip_serializing_if = "Option::is_none")] - 1110
pub synthesis_model: Option<String>, - 1111
/// **Deprecated** (docs/design/45-prompt-layers.md): the bot/chat - 1112
/// `identity` prompt block is the persona now, so a bot's spoken and - 1113
/// written selves cannot drift apart. Still read as a fallback when no - 1114
/// prompt tier sets an identity, and still honoured as an explicit - 1115
/// per-request override, so existing configs keep working. New writes - 1116
/// should set the `identity` block instead. - 1117
#[serde(default, skip_serializing_if = "Option::is_none")] - 1118
pub persona: Option<String>, - 1119
} - 1120
- 1121
impl VoiceConfig { - 1122
/// Overlay a narrower scope onto its parent. Missing fields inherit. - 1123
pub fn overlay(parent: Option<&Self>, child: &Self) -> Self { - 1124
Self { - 1125
provider: child - 1126
.provider - 1127
.clone() - 1128
.or_else(|| parent.and_then(|v| v.provider.clone())), - 1129
voice_name: child - 1130
.voice_name - 1131
.clone() - 1132
.or_else(|| parent.and_then(|v| v.voice_name.clone())), - 1133
transcription_model: child - 1134
.transcription_model - 1135
.clone() - 1136
.or_else(|| parent.and_then(|v| v.transcription_model.clone())), - 1137
synthesis_model: child - 1138
.synthesis_model - 1139
.clone() - 1140
.or_else(|| parent.and_then(|v| v.synthesis_model.clone())), - 1141
persona: child - 1142
.persona - 1143
.clone() - 1144
.or_else(|| parent.and_then(|v| v.persona.clone())), - 1145
} - 1146
} - 1147
} - 1148
- 1149
// Note: the `Bot` entity itself (id/surface/label/token_env/policy/ - 1150
// permission_mode/route/workspace) lives in `vak-server::gateway` next to - 1151
// `AllowlistEntry` and `AllowlistRoute`, since it needs `AllowlistRoute` and - 1152
// vak-config must not depend on vak-server. It reuses `ChannelPolicy::merge` - 1153
// and `PermissionMode::capped_by` from here for its slot in the bot → chat → - 1154
// workspace resolution chain. - 1155
- 1156
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1157
pub struct HookConfig { - 1158
pub event: String, - 1159
#[serde(rename = "match")] - 1160
pub matcher: Option<String>, - 1161
pub command: String, - 1162
pub timeout_ms: Option<u64>, - 1163
/// Missing in an existing `config.toml` means enabled — this field was
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.