- 281
/// and `route` are. - 282
#[serde( - 283
default, - 284
skip_serializing_if = "vak_core::prompts::LayerContent::is_empty" - 285
)] - 286
pub prompt: vak_core::prompts::LayerContent, - 287
} - 288
- 289
pub(crate) fn default_true() -> bool { - 290
true - 291
} - 292
- 293
/// Deserializer for a PATCH field shaped `Option<Option<T>>`, where the - 294
/// three JSON states must stay distinguishable: the key absent ("leave - 295
/// this alone"), the key present as `null` ("clear it"), and the key - 296
/// present with a value ("set it"). A plain `Option<Option<T>>` field - 297
/// cannot do this on its own — serde's derived `deserialize_option` maps - 298
/// JSON `null` to the *outer* `None`, identical to the key being absent, - 299
/// so "explicit null clears it" silently never fires - 300
/// (<https://github.com/serde-rs/serde/issues/984>). Pair with - 301
/// `#[serde(default, deserialize_with = "deserialize_present")]`: the - 302
/// `default` only ever applies when the key is missing entirely (serde - 303
/// skips `deserialize_with` in that case), and this function itself - 304
/// wraps whatever it sees — including a `null` that becomes `Some(None)` - 305
/// — in the outer `Some`. - 306
pub(crate) fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error> - 307
where - 308
T: serde::Deserialize<'de>, - 309
D: serde::Deserializer<'de>, - 310
{ - 311
T::deserialize(deserializer).map(Some) - 312
} - 313
- 314
fn is_default_channel_policy(policy: &vak_config::ChannelPolicy) -> bool { - 315
policy == &vak_config::ChannelPolicy::default() - 316
} - 317
- 318
#[derive(serde::Serialize, serde::Deserialize)] - 319
struct AllowlistFile { - 320
schema: u32, - 321
entries: Vec<AllowlistEntry>, - 322
} - 323
- 324
/// A gateway bot identity: one credential/token slot, independently - 325
/// addressable even when it shares a `surface` with other bots. Sits - 326
/// between the workspace and a chat's `AllowlistEntry` in the - 327
/// policy/permission/route resolution chain (`core_for_entry`, - 328
/// `resolve_channel_permission`) — see `vak_config::ChannelPolicy::merge` - 329
/// and `vak_config::PermissionMode::capped_by`. - 330
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] - 331
pub struct Bot { - 332
/// Stable slug, e.g. "telegram-support". Chosen at creation, immutable. - 333
pub id: String, - 334
/// "telegram" | "discord" | "slack". - 335
pub surface: String, - 336
/// Operator-facing name shown in the admin console. - 337
pub label: String, - 338
/// Name of the env var holding this bot's token. The token value - 339
/// itself is never stored here or returned by the admin API. - 340
pub token_env: String, - 341
/// Default agent identity this bot binds to (e.g. "support", "researcher"). - 342
/// If unset, resolves to "vak" (built-in default agent). - 343
#[serde(default, skip_serializing_if = "Option::is_none")] - 344
pub agent_id: Option<String>, - 345
#[serde(default, skip_serializing_if = "is_default_channel_policy")] - 346
pub policy: vak_config::ChannelPolicy, - 347
#[serde(default, skip_serializing_if = "Option::is_none")] - 348
pub permission_mode: Option<vak_config::PermissionMode>, - 349
#[serde(default, skip_serializing_if = "Option::is_none")] - 350
pub route: Option<AllowlistRoute>, - 351
#[serde(default, skip_serializing_if = "Option::is_none")] - 352
pub workspace: Option<PathBuf>, - 353
/// Voice/persona override for this bot's spoken replies. `None` - 354
/// inherits the workspace default (no voice); `Some` sets this bot's - 355
/// tier for any chat that inherits it. - 356
#[serde(default, skip_serializing_if = "Option::is_none")] - 357
pub voice: Option<vak_config::VoiceConfig>, - 358
/// Prompt tier for this bot (docs/design/45). Identity and rules fall - 359
/// through to the chat tier below; guardrails concatenate and cannot be - 360
/// removed by anything narrower. - 361
#[serde( - 362
default, - 363
skip_serializing_if = "vak_core::prompts::LayerContent::is_empty" - 364
)] - 365
pub prompt: vak_core::prompts::LayerContent, - 366
} - 367
- 368
#[derive(serde::Serialize, serde::Deserialize, Default)] - 369
struct BotsFile { - 370
schema: u32, - 371
bots: Vec<Bot>, - 372
} - 373
- 374
/// Outcome of resolving an inbound key against the allowlist store, so the - 375
/// caller can distinguish "just became pending" from "still pending" from - 376
/// a flat denial without re-deriving it from mutable state. - 377
pub(crate) enum AllowlistDecision { - 378
Allowed, - 379
Denied, - 380
NewlyPending, - 381
StillPending, - 382
} - 383
- 384
/// The resolved approval policy (docs/design/22-gateway.md G2), held as one - 385
/// value so the three fields can never be observed mid-update. - 386
/// - 387
/// This is behind a lock rather than being plain fields because the policy - 388
/// is now settable at runtime: it decides whether an `Ask` on a chat - 389
/// surface reaches a human at all, and an operator who changes it must see - 390
/// the next inbound message honour the change without restarting the - 391
/// process. `forward` without a target is not representable — the - 392
/// constructor and the setter both collapse that case to `deny`, which is - 393
/// the same rule `vak_config`'s loader applies. - 394
#[derive(Debug, Clone, PartialEq, Eq)] - 395
pub(crate) struct ApprovalPolicy { - 396
pub(crate) approvals: String, - 397
pub(crate) approver: Option<String>, - 398
pub(crate) timeout: Duration, - 399
} - 400
- 401
impl ApprovalPolicy { - 402
/// Build a policy, collapsing an unbacked `forward` to `deny`. - 403
/// A target must carry a `<surface>:<chat>` separator to count. - 404
pub(crate) fn resolve( - 405
approvals: &str, - 406
approver: Option<&str>, - 407
timeout: Duration, - 408
) -> ApprovalPolicy { - 409
let target = approver - 410
.map(str::trim) - 411
.filter(|t| !t.is_empty() && t.contains(':')); - 412
let forward = approvals == "forward" && target.is_some(); - 413
ApprovalPolicy { - 414
approvals: if forward { "forward" } else { "deny" }.into(), - 415
approver: forward.then(|| target.unwrap_or_default().to_string()), - 416
timeout, - 417
} - 418
} - 419
} - 420
- 421
pub struct GatewayState { - 422
pub enabled: bool, - 423
bindings: Mutex<HashMap<String, ChannelBinding>>, - 424
/// Resolved approval policy (docs/design/22-gateway.md G2). Mutable at - 425
/// runtime through [`GatewayState::set_approval_policy`]. - 426
approval_policy: Mutex<ApprovalPolicy>, - 427
/// Forwarded gates awaiting a yes/no from the approver surface, - 428
/// oldest first (uuidv7 keys sort by insertion time). - 429
pending_approvals: Mutex<std::collections::BTreeMap<String, PendingGate>>, - 430
chat_allowlist_open: bool, - 431
/// Live, schema-versioned allowlist store (docs/design/34). Authoritative - 432
/// once it exists on disk; seeded once from `chat_allowlist` otherwise. - 433
allowlist: Mutex<HashMap<String, AllowlistEntry>>, - 434
/// Bot identities (docs/design/34, multi-bot). Keyed by `Bot::id`. - 435
/// Independent of `allowlist`/`bindings` on purpose: several chats can - 436
/// share a bot, and a bot can exist with no chats bound to it yet. - 437
bots: Mutex<HashMap<String, Bot>>, - 438
/// Multi-tenant Core pool (docs/design/34 Phase 2). The gateway's own - 439
/// default workspace is the pool's permanent entry; every other - 440
/// workspace an allowlist entry names is lazily started here. - 441
pub(crate) core_pool: crate::core_pool::CorePool, - 442
} - 443
- 444
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] - 445
pub(crate) struct ChannelBinding { - 446
#[serde(default, skip_serializing_if = "Option::is_none")] - 447
pub session_id: Option<String>, - 448
#[serde(default, skip_serializing_if = "Option::is_none")] - 449
pub provider: Option<String>, - 450
#[serde(default, skip_serializing_if = "Option::is_none")] - 451
pub model: Option<String>, - 452
#[serde(default, skip_serializing_if = "Option::is_none")] - 453
pub workspace: Option<PathBuf>, - 454
#[serde(default, skip_serializing_if = "Option::is_none")] - 455
pub route_revision: Option<String>, - 456
} - 457
- 458
#[derive(serde::Serialize, serde::Deserialize)] - 459
struct BindingsFile { - 460
version: u32, - 461
bindings: HashMap<String, ChannelBinding>, - 462
} - 463
- 464
#[derive(serde::Deserialize)] - 465
#[serde(untagged)] - 466
enum StoredBindings { - 467
Versioned(BindingsFile), - 468
Legacy(HashMap<String, String>), - 469
} - 470
- 471
struct PendingGate { - 472
session_id: String, - 473
tx: oneshot::Sender<bool>, - 474
} - 475
- 476
/// What a resolved gate was, so a bare yes/no is never silent about which - 477
/// session's tool run it just decided. - 478
pub(crate) struct ResolvedGate { - 479
pub id: String, - 480
pub session_id: String, - 481
pub remaining: usize, - 482
} - 483
- 484
impl GatewayState { - 485
/// Load persisted bindings; `force` overrides the config gate - 486
/// (`serve --gateway`). - 487
pub fn load(core: &Core, force: bool) -> Self { - 488
let mut bindings = HashMap::new(); - 489
if let Ok(raw) = std::fs::read_to_string(bindings_path(&core.shared_data_home())) - 490
&& let Ok(stored) = serde_json::from_str::<StoredBindings>(&raw) - 491
{ - 492
bindings = match stored { - 493
StoredBindings::Versioned(file) => file.bindings, - 494
StoredBindings::Legacy(map) => map - 495
.into_iter() - 496
.map(|(key, session_id)| { - 497
( - 498
key, - 499
ChannelBinding { - 500
session_id: Some(session_id), - 501
..ChannelBinding::default() - 502
}, - 503
) - 504
}) - 505
.collect(), - 506
}; - 507
} - 508
let gw = &core.config().gateway; - 509
- 510
// Allowlist store: authoritative once allowlist.json exists; a - 511
// one-time import from config.toml's `chat_allowlist` seeds it the - 512
// first time a process ever loads (same relationship bindings.json - 513
// already has to route overrides — config.toml itself is untouched). - 514
let path = allowlist_path(&core.shared_data_home()); - 515
let allowlist: HashMap<String, AllowlistEntry> = match std::fs::read_to_string(&path) { - 516
Ok(raw) => serde_json::from_str::<AllowlistFile>(&raw) - 517
.map(|file| { - 518
file.entries - 519
.into_iter() - 520
.map(|e| (e.key.clone(), e)) - 521
.collect() - 522
}) - 523
.unwrap_or_default(), - 524
Err(_) => { - 525
let now = chrono::Utc::now().to_rfc3339(); - 526
let seeded: HashMap<String, AllowlistEntry> = gw - 527
.chat_allowlist - 528
.iter() - 529
.map(|key| { - 530
( - 531
key.clone(), - 532
AllowlistEntry { - 533
key: key.clone(), - 534
status: AllowlistStatus::Allowed, - 535
workspace: None, - 536
agent_id: Some("vak".into()), - 537
route: None, - 538
voice: None, - 539
permission_mode: None, - 540
policy: vak_config::ChannelPolicy::default(), - 541
added_at: now.clone(), - 542
added_by: "config_import".into(), - 543
first_seen_text: None, - 544
prompt: Default::default(), - 545
bot_id: None, - 546
inherit_bot_policy: true, - 547
}, - 548
) - 549
}) - 550
.collect(); - 551
if !seeded.is_empty() { - 552
write_allowlist_file(&path, &seeded); - 553
} - 554
seeded - 555
} - 556
}; - 557
- 558
// Bot store. There is no migration from a per-surface token slot: - 559
// those are deleted (AGENTS.md invariants 23 and 29), and - 560
// synthesizing a bot from one would be exactly the pre-baseline - 561
// fold-forward the baseline forbids. A bot is created explicitly, - 562
// through setup or the admin console, and owns its own token env. - 563
let bots_file_path = bots_path(&core.shared_data_home()); - 564
let bots: HashMap<String, Bot> = match std::fs::read_to_string(&bots_file_path) { - 565
Ok(raw) => serde_json::from_str::<BotsFile>(&raw) - 566
.map(|file| file.bots.into_iter().map(|b| (b.id.clone(), b)).collect()) - 567
.unwrap_or_default(), - 568
// No bots.json yet means no bots. Not an error. - 569
Err(_) => HashMap::new(), - 570
}; - 571
- 572
let state = GatewayState { - 573
enabled: force || gw.enabled, - 574
bindings: Mutex::new(bindings), - 575
bots: Mutex::new(bots), - 576
approval_policy: Mutex::new(ApprovalPolicy::resolve( - 577
&gw.approvals, - 578
gw.approver.as_deref(), - 579
Duration::from_secs(gw.approval_timeout_secs), - 580
)), - 581
pending_approvals: Mutex::new(std::collections::BTreeMap::new()), - 582
chat_allowlist_open: gw.chat_allowlist_open, - 583
allowlist: Mutex::new(allowlist), - 584
core_pool: crate::core_pool::CorePool::new( - 585
core.clone(), - 586
gw.core_pool_max, - 587
Duration::from_secs(gw.core_pool_idle_secs), - 588
), - 589
}; - 590
// docs/design/34: a pending request nobody acted on inside the - 591
// expiry window auto-denies (visibly, `added_by = "expiry"`). - 592
// `vak doctor --repair` does the same thing offline against the - 593
// store; doing it here too means a restarted gateway self-heals - 594
// and the two paths converge on the same state. - 595
let expired = state - 596
.allowlist_expire_pending(core, chrono::Duration::days(gw.pending_expiry_days as i64)); - 597
for key in expired { - 598
vak_core::security_events::record( - 599
&core.sessions_home(), - 600
vak_core::security_events::EventKind::ChatDenied, - 601
"chat_denied", - 602
&format!("key={key} reason=expiry"), - 603
None, - 604
); - 605
} - 606
state - 607
} - 608
- 609
/// Resolve the `Core` a channel's entry should actually run through: - 610
/// the pool's default entry when the entry has no workspace override or - 611
/// names the gateway's own workspace, otherwise the (lazily started) - 612
/// pooled `Core` for that workspace. This is the Phase 2 seam that - 613
/// makes an allowlist entry's `workspace` field actually run that - 614
/// workspace's own sandbox/permission/session state, not just pick its - 615
/// provider/model. - 616
pub(crate) fn core_for_entry(&self, default_core: &Core, key: &str) -> Result<Core, String> { - 617
let entry = self.allowlist_get(key); - 618
let allowed_entry = entry - 619
.as_ref() - 620
.filter(|e| e.status == AllowlistStatus::Allowed); - 621
// Bot tier: only consulted when the chat both names a bot and has - 622
// not opted out of inheriting from it (`inherit_bot_policy`). A - 623
// dangling `bot_id` (removed bot) resolves as "no bot tier", same - 624
// as an unset one — never a hard failure at dispatch. - 625
let bot = allowed_entry - 626
.filter(|e| e.inherit_bot_policy) - 627
.and_then(|e| e.bot_id.as_deref()) - 628
.and_then(|id| self.bot_get(id)); - 629
let workspace = allowed_entry - 630
.and_then(|e| e.workspace.clone()) - 631
.or_else(|| bot.as_ref().and_then(|b| b.workspace.clone())) - 632
.unwrap_or_else(|| default_core.cwd().clone()); - 633
- 634
// Policy: bot policy (lower tier) folded under the chat's own - 635
// (higher tier) via the same restrictive-only merge used to - 636
// reconcile any two policy layers. - 637
let chat_policy = allowed_entry.map(|e| e.policy.clone()).unwrap_or_default(); - 638
let policy = match &bot { - 639
Some(b) => vak_config::ChannelPolicy::merge(&b.policy, &chat_policy), - 640
None => chat_policy, - 641
}; - 642
- 643
// Permission mode: chat pin capped by bot mode (itself already - 644
// capped by the workspace inside `resolve_at_with_policy`) so a bot - 645
// can narrow but never widen what the workspace allows, and a chat - 646
// can narrow but never widen what its bot allows. - 647
let permission_override = match (allowed_entry.and_then(|e| e.permission_mode), &bot) { - 648
(Some(chat_mode), Some(b)) => Some(match b.permission_mode { - 649
Some(bot_mode) => chat_mode.capped_by(bot_mode), - 650
None => chat_mode, - 651
}), - 652
(Some(chat_mode), None) => Some(chat_mode), - 653
(None, Some(b)) => b.permission_mode, - 654
(None, None) => None, - 655
}; - 656
- 657
let resolved = self.core_pool.resolve_at_with_policy( - 658
&workspace, - 659
permission_override, - 660
policy, - 661
std::time::Instant::now(), - 662
)?; - 663
let selected_agent = allowed_entry - 664
.and_then(|entry| { - 665
let entry_agent = entry.agent_id.as_deref(); - 666
let bot_agent = bot.as_ref().and_then(|b| b.agent_id.as_deref()); - 667
if entry.inherit_bot_policy - 668
&& (entry_agent.is_none() || entry_agent == Some("vak")) - 669
&& bot_agent.is_some() - 670
{ - 671
return bot_agent; - 672
} - 673
entry_agent - 674
}) - 675
.or_else(|| bot.as_ref().and_then(|b| b.agent_id.as_deref())) - 676
.unwrap_or("vak"); - 677
let identity = if selected_agent == "vak" { - 678
vak_core::vak_agent_identity() - 679
} else { - 680
let profiles = crate::agents::effective(&resolved) - 681
.map_err(|error| format!("agent catalog unavailable: {error}"))?; - 682
profiles - 683
.into_iter() - 684
.find(|profile| profile.id == selected_agent) - 685
.filter(|profile| profile.is_admissible()) - 686
.map(|profile| profile.identity()) - 687
.ok_or_else(|| format!("configured Agent '{selected_agent}' is unavailable"))? - 688
}; - 689
Ok(resolved.with_agent_identity(Some(identity))) - 690
} - 691
- 692
pub(crate) fn workspace_for_entry(&self, default_core: &Core, key: &str) -> PathBuf { - 693
let entry = self.allowlist_get(key); - 694
let allowed = entry - 695
.as_ref() - 696
.filter(|e| e.status == AllowlistStatus::Allowed); - 697
let bot = allowed - 698
.filter(|e| e.inherit_bot_policy) - 699
.and_then(|e| e.bot_id.as_deref()) - 700
.and_then(|id| self.bot_get(id)); - 701
allowed - 702
.and_then(|e| e.workspace.clone()) - 703
.or_else(|| bot.and_then(|b| b.workspace)) - 704
.unwrap_or_else(|| default_core.cwd().to_path_buf()) - 705
} - 706
- 707
pub(crate) fn workspace_override_for_entry(&self, key: &str) -> Option<PathBuf> { - 708
let entry = self.allowlist_get(key)?; - 709
if entry.status != AllowlistStatus::Allowed { - 710
return None; - 711
} - 712
let bot = entry - 713
.inherit_bot_policy - 714
.then_some(entry.bot_id.as_deref()) - 715
.flatten() - 716
.and_then(|id| self.bot_get(id)); - 717
entry.workspace.or_else(|| bot.and_then(|b| b.workspace)) - 718
} - 719
- 720
pub(crate) fn set_enabled(&mut self, enabled: bool) { - 721
self.enabled = enabled; - 722
} - 723
- 724
/// Snapshot of current surface bindings (key→value). - 725
pub(crate) fn bindings_snapshot(&self) -> Vec<(String, ChannelBinding)> { - 726
self.bindings - 727
.lock() - 728
.unwrap_or_else(|p| p.into_inner()) - 729
.iter() - 730
.map(|(k, v)| (k.clone(), v.clone())) - 731
.collect() - 732
} - 733
- 734
/// True when forwarded gates are active. - 735
fn approval_policy(&self) -> ApprovalPolicy { - 736
self.approval_policy - 737
.lock() - 738
.unwrap_or_else(std::sync::PoisonError::into_inner) - 739
.clone() - 740
} - 741
- 742
pub(crate) fn forward_mode(&self) -> bool { - 743
let policy = self.approval_policy(); - 744
self.enabled && policy.approvals == "forward" && policy.approver.is_some() - 745
} - 746
- 747
/// The chat that answers forwarded gates. Returns an owned `String` - 748
/// rather than a borrow because the policy now lives behind a lock — - 749
/// handing out a reference into it would either hold the lock across - 750
/// an await or dangle. - 751
pub(crate) fn approver_target(&self) -> Option<String> { - 752
self.approval_policy().approver - 753
} - 754
- 755
pub(crate) fn approval_timeout(&self) -> Duration { - 756
self.approval_policy().timeout - 757
} - 758
- 759
pub(crate) fn approvals_mode(&self) -> String { - 760
self.approval_policy().approvals - 761
} - 762
- 763
/// Chats that could serve as the forwarded-approval target, as - 764
/// `<surface>:<chat>` delivery addresses. - 765
/// - 766
/// An allowlist key may be bot-scoped (`telegram:12345:vakyartha`); - 767
/// that third segment identifies the bot the message arrived through, - 768
/// not a place a reply can be delivered. `deliver_to` addresses are - 769
/// two-part, so the key is truncated here rather than at every reader. - 770
/// Only `Allowed` entries are offered: forwarding a gate to a pending - 771
/// or denied chat would announce it somewhere the operator has - 772
/// explicitly not admitted. - 773
pub(crate) fn approver_candidates(&self) -> Vec<String> { - 774
let mut out: Vec<String> = self - 775
.allowlist - 776
.lock() - 777
.unwrap_or_else(std::sync::PoisonError::into_inner) - 778
.values() - 779
.filter(|entry| entry.status == AllowlistStatus::Allowed) - 780
.filter_map(|entry| { - 781
let mut parts = entry.key.splitn(3, ':'); - 782
match (parts.next(), parts.next()) { - 783
(Some(surface), Some(chat)) if !surface.is_empty() && !chat.is_empty() => { - 784
Some(format!("{surface}:{chat}")) - 785
} - 786
_ => None, - 787
} - 788
}) - 789
.collect(); - 790
out.sort(); - 791
out.dedup(); - 792
out - 793
} - 794
- 795
/// Replace the live approval policy. Returns the policy actually - 796
/// installed, which is [`ApprovalPolicy::resolve`]'s answer — asking - 797
/// for `forward` with no usable target installs `deny`, so a caller - 798
/// can compare and tell the operator their request was reduced instead - 799
/// of reporting a success that did not happen. - 800
/// - 801
/// In-flight forwarded gates are NOT resolved here. They were raised - 802
/// under the old policy and already have an announcement sitting in the - 803
/// approver's chat; cancelling them would strand a run that a human is - 804
/// actively about to answer. Narrowing to `deny` stops the NEXT gate, - 805
/// which is the guarantee that matters (nothing new reaches a chat that - 806
/// should no longer be asked). - 807
pub(crate) fn set_approval_policy(&self, next: ApprovalPolicy) -> ApprovalPolicy { - 808
let resolved = - 809
ApprovalPolicy::resolve(&next.approvals, next.approver.as_deref(), next.timeout); - 810
*self - 811
.approval_policy - 812
.lock() - 813
.unwrap_or_else(std::sync::PoisonError::into_inner) = resolved.clone(); - 814
resolved - 815
} - 816
- 817
/// True when an empty `chat_allowlist` was explicitly opted into - 818
/// staying open. Defaults to false: fail closed (0c-02). - 819
pub(crate) fn chat_allowlist_open(&self) -> bool { - 820
self.chat_allowlist_open - 821
} - 822
- 823
pub(crate) fn pending_approval_count(&self) -> usize { - 824
self.pending_approvals - 825
.lock() - 826
.unwrap_or_else(std::sync::PoisonError::into_inner) - 827
.len() - 828
} - 829
- 830
/// Register a gate for `session_id` and hand back the reply receiver. - 831
/// The sender must be stored before the request is announced so an - 832
/// instant reply cannot race a missing entry. - 833
pub(crate) fn register_gate(&self, id: &str, session_id: &str) -> oneshot::Receiver<bool> { - 834
let (tx, rx) = oneshot::channel(); - 835
self.pending_approvals - 836
.lock() - 837
.unwrap_or_else(std::sync::PoisonError::into_inner) - 838
.insert( - 839
id.to_string(), - 840
PendingGate { - 841
session_id: session_id.to_string(), - 842
tx, - 843
}, - 844
); - 845
rx - 846
} - 847
- 848
/// Resolve a gate. With an id prefix, only that exact gate resolves — - 849
/// a reply meant for one session can never approve another's tool run. - 850
/// Without one, the globally oldest gate resolves and is reported so - 851
/// the approver surface can see what their bare yes/no did. - 852
pub(crate) fn resolve_gate( - 853
&self, - 854
approve: bool, - 855
id_prefix: Option<&str>, - 856
) -> Result<ResolvedGate, ()> { - 857
let mut map = self - 858
.pending_approvals - 859
.lock() - 860
.unwrap_or_else(std::sync::PoisonError::into_inner); - 861
let key = match id_prefix { - 862
Some(prefix) => match map.keys().find(|k| k.starts_with(prefix)).cloned() { - 863
Some(k) => k, - 864
None => return Err(()), - 865
}, - 866
None => map.keys().next().cloned().ok_or(())?, - 867
}; - 868
let (_, gate) = map.remove_entry(&key).ok_or(())?; - 869
let _ = gate.tx.send(approve); - 870
Ok(ResolvedGate { - 871
id: key, - 872
session_id: gate.session_id, - 873
remaining: map.len(), - 874
}) - 875
} - 876
- 877
/// Reject forwarded approval gates belonging to a revoked session. A - 878
/// late reply then finds no gate and cannot authorize stale work. - 879
pub(crate) fn deny_pending_for_session(&self, session_id: &str) -> usize { - 880
let mut pending = self - 881
.pending_approvals - 882
.lock() - 883
.unwrap_or_else(std::sync::PoisonError::into_inner); - 884
let keys: Vec<String> = pending - 885
.iter() - 886
.filter(|(_, gate)| gate.session_id == session_id) - 887
.map(|(id, _)| id.clone()) - 888
.collect(); - 889
let mut denied = 0; - 890
for key in keys { - 891
if let Some(gate) = pending.remove(&key) { - 892
let _ = gate.tx.send(false); - 893
denied += 1; - 894
} - 895
} - 896
denied - 897
} - 898
- 899
pub(crate) fn snapshot(&self) -> Vec<(String, ChannelBinding)> { - 900
let mut pairs: Vec<(String, ChannelBinding)> = self - 901
.bindings - 902
.lock() - 903
.unwrap_or_else(std::sync::PoisonError::into_inner) - 904
.iter() - 905
.map(|(k, v)| (k.clone(), v.clone())) - 906
.collect(); - 907
pairs.sort_by(|a, b| a.0.cmp(&b.0)); - 908
pairs - 909
} - 910
- 911
fn bind(&self, core: &Core, key: String, session_id: String, revision: String) { - 912
let mut bindings = self - 913
.bindings - 914
.lock() - 915
.unwrap_or_else(std::sync::PoisonError::into_inner); - 916
let binding = bindings.entry(key).or_default(); - 917
binding.session_id = Some(session_id); - 918
binding.workspace = Some(core.cwd().clone()); - 919
binding.route_revision = Some(revision); - 920
drop(bindings); - 921
persist_bindings(core, self); - 922
} - 923
- 924
pub(crate) fn set_route_override( - 925
&self, - 926
core: &Core, - 927
key: String, - 928
route: Option<(String, String)>, - 929
) { - 930
let mut bindings = self - 931
.bindings - 932
.lock() - 933
.unwrap_or_else(std::sync::PoisonError::into_inner); - 934
let binding = bindings.entry(key).or_default(); - 935
match route { - 936
Some((provider, model)) => { - 937
binding.provider = Some(provider); - 938
binding.model = Some(model); - 939
} - 940
None => { - 941
binding.provider = None; - 942
binding.model = None; - 943
} - 944
} - 945
binding.route_revision = None; - 946
drop(bindings); - 947
persist_bindings(core, self); - 948
} - 949
- 950
pub(crate) fn rotate(&self, core: &Core, key: &str) -> bool { - 951
let mut bindings = self - 952
.bindings - 953
.lock() - 954
.unwrap_or_else(std::sync::PoisonError::into_inner); - 955
let Some(binding) = bindings.get_mut(key) else { - 956
return false; - 957
}; - 958
binding.session_id = None; - 959
binding.route_revision = None; - 960
drop(bindings); - 961
persist_bindings(core, self); - 962
true - 963
} - 964
- 965
pub(crate) fn unbind(&self, core: &Core, key: &str) -> bool { - 966
let removed = self - 967
.bindings - 968
.lock() - 969
.unwrap_or_else(std::sync::PoisonError::into_inner) - 970
.remove(key) - 971
.is_some(); - 972
if removed { - 973
persist_bindings(core, self); - 974
} - 975
removed - 976
} - 977
- 978
// ---- Bot store (multi-bot-per-channel) --------------------------------- - 979
- 980
/// Snapshot of all bots, sorted by id. Secrets never included — a `Bot` - 981
/// row only ever holds the env var *name*, not the token value. - 982
pub(crate) fn bots_snapshot(&self) -> Vec<Bot> { - 983
let mut bots: Vec<Bot> = self - 984
.bots - 985
.lock() - 986
.unwrap_or_else(std::sync::PoisonError::into_inner) - 987
.values() - 988
.cloned() - 989
.collect(); - 990
bots.sort_by(|a, b| a.id.cmp(&b.id)); - 991
bots - 992
} - 993
- 994
pub(crate) fn bot_get(&self, id: &str) -> Option<Bot> { - 995
self.bots - 996
.lock() - 997
.unwrap_or_else(std::sync::PoisonError::into_inner) - 998
.get(id) - 999
.cloned() - 1000
} - 1001
- 1002
/// Insert or replace a bot row wholesale (create, rename label, or edit - 1003
/// policy/permission_mode/route/workspace). `token_env` is set - 1004
/// separately from the actual secret by the caller before this is - 1005
/// invoked, keeping the write here free of the token value itself. - 1006
pub(crate) fn bot_upsert(&self, core: &Core, bot: Bot) { - 1007
let id = bot.id.clone(); - 1008
self.bots - 1009
.lock() - 1010
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1011
.insert(id.clone(), bot); - 1012
persist_bots(core, self); - 1013
self.invalidate_bindings_for_bot(core, &id); - 1014
} - 1015
- 1016
/// Remove a bot row. Chats whose `bot_id` names it keep the id on - 1017
/// record (a dangling reference resolves as "no bot" at dispatch, - 1018
/// same as an unset `bot_id`) rather than being silently rewritten. - 1019
pub(crate) fn bot_remove(&self, core: &Core, id: &str) -> bool { - 1020
let removed = self - 1021
.bots - 1022
.lock() - 1023
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1024
.remove(id) - 1025
.is_some(); - 1026
if removed { - 1027
persist_bots(core, self); - 1028
self.invalidate_bindings_for_bot(core, id); - 1029
} - 1030
removed - 1031
} - 1032
- 1033
// ---- Allowlist store (docs/design/34-channel-onboarding.md) ----------- - 1034
- 1035
/// Snapshot of all allowlist entries, any status, sorted by key. - 1036
pub(crate) fn allowlist_snapshot(&self) -> Vec<AllowlistEntry> { - 1037
let mut entries: Vec<AllowlistEntry> = self - 1038
.allowlist - 1039
.lock() - 1040
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1041
.values() - 1042
.cloned() - 1043
.collect(); - 1044
entries.sort_by(|a, b| a.key.cmp(&b.key)); - 1045
entries - 1046
} - 1047
- 1048
pub(crate) fn allowlist_get(&self, key: &str) -> Option<AllowlistEntry> { - 1049
self.allowlist - 1050
.lock() - 1051
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1052
.get(key) - 1053
.cloned() - 1054
} - 1055
- 1056
/// Resolve an inbound key against the store: creates a pending entry on - 1057
/// first sight, never duplicates or bumps `added_at` on a repeat - 1058
/// message from an already-pending key. - 1059
pub(crate) fn allowlist_resolve_inbound( - 1060
&self, - 1061
core: &Core, - 1062
key: &str, - 1063
first_seen_text: &str, - 1064
bot_id: Option<&str>, - 1065
) -> AllowlistDecision { - 1066
let mut map = self - 1067
.allowlist - 1068
.lock() - 1069
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1070
// Distinct from `decision` below: repeat traffic from an already- - 1071
// `Allowed` key takes the same `AllowlistDecision::Allowed` value - 1072
// as the freshly-inherited case, but must not re-persist the file - 1073
// on every single inbound message — only a real map mutation - 1074
// (a fresh Pending row, or a fresh inherited-Allowed row) should. - 1075
let mut mutated = false; - 1076
let decision = match map.get(key).map(|e| e.status) { - 1077
Some(AllowlistStatus::Allowed) => AllowlistDecision::Allowed, - 1078
Some(AllowlistStatus::Denied) => AllowlistDecision::Denied, - 1079
Some(AllowlistStatus::Pending) => AllowlistDecision::StillPending, - 1080
None => { - 1081
// Multi-bot-per-channel (docs/design/34 Phase 5 follow-up): - 1082
// a bot-scoped key (`surface:chat:bot_id`) seen for the - 1083
// first time inherits an already-allowed legacy - 1084
// (`surface:chat`) entry's approval/workspace/policy when - 1085
// one exists — the same physical chat an operator already - 1086
// trusted, just now seen through a second bot. Without - 1087
// this, every existing approved chat would need a needless - 1088
// re-approval the moment its bridge started sending a - 1089
// bot id, and a `chat_allowlist` entry in config.toml - 1090
// (also just a row in this same map) would silently stop - 1091
// matching too. - 1092
if let Some(legacy_key) = legacy_key_for(key) - 1093
&& let Some(legacy) = map.get(&legacy_key).cloned() - 1094
&& legacy.status == AllowlistStatus::Allowed - 1095
{ - 1096
map.insert( - 1097
key.to_string(), - 1098
AllowlistEntry { - 1099
key: key.to_string(), - 1100
bot_id: bot_id.map(str::to_string), - 1101
added_by: format!("gateway (inherited from {legacy_key})"), - 1102
added_at: chrono::Utc::now().to_rfc3339(), - 1103
first_seen_text: None, - 1104
prompt: Default::default(), - 1105
..legacy - 1106
}, - 1107
); - 1108
// The legacy row's *allowlist entry* stays — a third - 1109
// bot arriving later needs it as the ancestor to - 1110
// inherit from too, same as this one just did. But its - 1111
// *binding/session* is now genuinely dead: every future - 1112
// message for this physical chat will always carry a - 1113
// bot id and therefore always route through a - 1114
// bot-scoped key, never this one again. Left bound, it - 1115
// would sit forever in the Chats list looking like a - 1116
// confusing duplicate of the bot-scoped row — the exact - 1117
// bug a live operator hit. `unbind` locks - 1118
// `self.bindings`, a different mutex than the `map` - 1119
// guard held here, so no deadlock. - 1120
self.unbind(core, &legacy_key); - 1121
mutated = true; - 1122
AllowlistDecision::Allowed - 1123
} else { - 1124
let truncated: String = first_seen_text - 1125
.chars() - 1126
.take(FIRST_SEEN_TEXT_MAX_CHARS) - 1127
.collect(); - 1128
let bot_agent = bot_id - 1129
.and_then(|id| self.bot_get(id)) - 1130
.and_then(|b| b.agent_id); - 1131
map.insert( - 1132
key.to_string(), - 1133
AllowlistEntry { - 1134
key: key.to_string(), - 1135
status: AllowlistStatus::Pending, - 1136
workspace: None, - 1137
agent_id: bot_agent.or_else(|| Some("vak".into())), - 1138
route: None, - 1139
voice: None, - 1140
permission_mode: None, - 1141
policy: vak_config::ChannelPolicy::default(), - 1142
added_at: chrono::Utc::now().to_rfc3339(), - 1143
added_by: "gateway".into(), - 1144
first_seen_text: Some(truncated), - 1145
prompt: Default::default(), - 1146
bot_id: bot_id.map(str::to_string), - 1147
inherit_bot_policy: true, - 1148
}, - 1149
); - 1150
mutated = true; - 1151
AllowlistDecision::NewlyPending - 1152
} - 1153
} - 1154
}; - 1155
if mutated { - 1156
drop(map); - 1157
persist_allowlist(core, self); - 1158
} - 1159
decision - 1160
} - 1161
- 1162
/// Approve a key: pending or unknown → allowed, with an explicit - 1163
/// workspace (never silently inherited) and optional route override. - 1164
#[allow(clippy::too_many_arguments)] - 1165
pub(crate) fn allowlist_approve( - 1166
&self, - 1167
core: &Core, - 1168
key: &str, - 1169
workspace: PathBuf, - 1170
agent_id: Option<String>, - 1171
route: Option<AllowlistRoute>, - 1172
permission_mode: Option<vak_config::PermissionMode>, - 1173
policy: vak_config::ChannelPolicy, - 1174
bot_id: Option<String>, - 1175
inherit_bot_policy: bool, - 1176
added_by: &str, - 1177
) -> AllowlistEntry { - 1178
let entry = { - 1179
let mut map = self - 1180
.allowlist - 1181
.lock() - 1182
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1183
let bot_agent = bot_id - 1184
.as_deref() - 1185
.and_then(|id| self.bot_get(id)) - 1186
.and_then(|b| b.agent_id); - 1187
let entry = AllowlistEntry { - 1188
key: key.to_string(), - 1189
status: AllowlistStatus::Allowed, - 1190
workspace: Some(workspace), - 1191
agent_id: agent_id.or(bot_agent).or_else(|| Some("vak".into())), - 1192
route, - 1193
voice: None, - 1194
permission_mode, - 1195
policy, - 1196
added_at: chrono::Utc::now().to_rfc3339(), - 1197
added_by: added_by.to_string(), - 1198
first_seen_text: None, - 1199
prompt: Default::default(), - 1200
bot_id, - 1201
inherit_bot_policy, - 1202
}; - 1203
map.insert(key.to_string(), entry.clone()); - 1204
entry - 1205
}; - 1206
persist_allowlist(core, self); - 1207
entry - 1208
} - 1209
- 1210
/// Deny a key: pending or unknown → denied (sticky). - 1211
pub(crate) fn allowlist_deny(&self, core: &Core, key: &str, added_by: &str) -> AllowlistEntry { - 1212
let entry = { - 1213
let mut map = self - 1214
.allowlist - 1215
.lock() - 1216
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1217
let entry = AllowlistEntry { - 1218
key: key.to_string(), - 1219
status: AllowlistStatus::Denied, - 1220
workspace: None, - 1221
agent_id: Some("vak".into()), - 1222
route: None, - 1223
voice: None, - 1224
permission_mode: None, - 1225
policy: vak_config::ChannelPolicy::default(), - 1226
added_at: chrono::Utc::now().to_rfc3339(), - 1227
added_by: added_by.to_string(), - 1228
first_seen_text: None, - 1229
prompt: Default::default(), - 1230
bot_id: None, - 1231
inherit_bot_policy: true, - 1232
}; - 1233
map.insert(key.to_string(), entry.clone()); - 1234
entry - 1235
}; - 1236
persist_allowlist(core, self); - 1237
entry - 1238
} - 1239
- 1240
/// Edit an already-`allowed` entry's `workspace`/`route` in place - 1241
/// (docs/design/34 "Editing an already-allowed entry"). Provenance - 1242
/// (`added_at`/`added_by`) is deliberately preserved — this is a - 1243
/// re-point, not a re-approval. `None` for either field clears it - 1244
/// (inherit the gateway workspace / the workspace's default route). - 1245
/// - 1246
/// The caller is expected to follow this with - 1247
/// [`GatewayState::invalidate_binding_revision`] so the change goes - 1248
/// through the same stale-session-rotation path - 1249
/// `PATCH .../bindings/{key}` already uses, rather than mutating - 1250
/// allowlist state the binding/session layer never learns about. - 1251
#[allow(clippy::too_many_arguments)] - 1252
pub(crate) fn allowlist_patch( - 1253
&self, - 1254
core: &Core, - 1255
key: &str, - 1256
workspace: Option<PathBuf>, - 1257
agent_id: Option<Option<String>>, - 1258
route: Option<AllowlistRoute>, - 1259
permission_mode: Option<vak_config::PermissionMode>, - 1260
policy: vak_config::ChannelPolicy, - 1261
bot_id: Option<Option<String>>, - 1262
inherit_bot_policy: Option<bool>, - 1263
voice: Option<Option<vak_config::VoiceConfig>>, - 1264
prompt: Option<vak_core::prompts::LayerContent>, - 1265
) -> Option<AllowlistEntry> { - 1266
let entry = { - 1267
let mut map = self - 1268
.allowlist - 1269
.lock() - 1270
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1271
let entry = map.get_mut(key)?; - 1272
if entry.status != AllowlistStatus::Allowed { - 1273
return None; - 1274
} - 1275
entry.workspace = workspace; - 1276
if let Some(agent_id) = agent_id { - 1277
entry.agent_id = agent_id.and_then(|s| { - 1278
let trimmed = s.trim(); - 1279
if trimmed.is_empty() { - 1280
None
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.