- 9391
PermissionMode::WorkspaceWrite, - 9392
PermissionMode::FullAccess, - 9393
] { - 9394
let d = decide(&cfg, &[], mode, "webfetch"); - 9395
assert!(matches!(d, Decision::Deny { .. }), "{mode:?}: {d:?}"); - 9396
} - 9397
} - 9398
- 9399
#[test] - 9400
fn explicit_ask_rule_is_honored_even_under_fullaccess() { - 9401
let mut cfg = vak_config::Config::default(); - 9402
cfg.ask.push("?webfetch".into()); - 9403
for mode in [ - 9404
PermissionMode::ReadOnly, - 9405
PermissionMode::WorkspaceWrite, - 9406
PermissionMode::FullAccess, - 9407
] { - 9408
let d = decide(&cfg, &[], mode, "webfetch"); - 9409
assert!(matches!(d, Decision::Ask { .. }), "{mode:?}: {d:?}"); - 9410
} - 9411
} - 9412
- 9413
#[test] - 9414
fn browse_shares_the_full_webfetch_mode_matrix() { - 9415
// Same family, same posture: restricted modes Ask by default, - 9416
// FullAccess allows, blanket allow lifts the Ask, blanket deny - 9417
// denies even under FullAccess, and an explicit ?ask is honored - 9418
// everywhere. - 9419
let base = vak_config::Config::default(); - 9420
for mode in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 9421
let d = decide(&base, &[], mode, "browse"); - 9422
assert!(matches!(d, Decision::Ask { .. }), "{mode:?}: {d:?}"); - 9423
} - 9424
let d = decide(&base, &[], PermissionMode::FullAccess, "browse"); - 9425
assert!(matches!(d, Decision::Allow), "FullAccess: {d:?}"); - 9426
- 9427
let mut allowed = vak_config::Config::default(); - 9428
allowed.allow.push("browse".into()); - 9429
for mode in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 9430
let d = decide(&allowed, &[], mode, "browse"); - 9431
assert!( - 9432
matches!(d, Decision::Allow), - 9433
"allow rule in {mode:?}: {d:?}" - 9434
); - 9435
} - 9436
- 9437
let mut denied = vak_config::Config::default(); - 9438
denied.deny.push("-browse".into()); - 9439
for mode in [ - 9440
PermissionMode::ReadOnly, - 9441
PermissionMode::WorkspaceWrite, - 9442
PermissionMode::FullAccess, - 9443
] { - 9444
let d = decide(&denied, &[], mode, "browse"); - 9445
assert!( - 9446
matches!(d, Decision::Deny { .. }), - 9447
"-browse in {mode:?}: {d:?}" - 9448
); - 9449
} - 9450
- 9451
let mut asked = vak_config::Config::default(); - 9452
asked.ask.push("?browse".into()); - 9453
let d = decide(&asked, &[], PermissionMode::FullAccess, "browse"); - 9454
assert!( - 9455
matches!(d, Decision::Ask { .. }), - 9456
"?browse under FullAccess: {d:?}" - 9457
); - 9458
} - 9459
- 9460
#[test] - 9461
fn deny_outranks_allow_regardless_of_layer_order() { - 9462
let mut cfg = vak_config::Config::default(); - 9463
cfg.allow.push("webfetch".into()); - 9464
cfg.deny.push("-webfetch".into()); - 9465
let dir = tempfile::tempdir().unwrap(); - 9466
let e = build_engine_with(&cfg, &[]).unwrap(); - 9467
let d = e.evaluate( - 9468
"webfetch", - 9469
&serde_json::json!({}), - 9470
Mode::WorkspaceWrite, - 9471
dir.path(), - 9472
); - 9473
assert!(matches!(d, Decision::Deny { .. })); - 9474
} - 9475
- 9476
#[test] - 9477
fn patterned_allow_cannot_lift_the_ask_default() { - 9478
// Patterned rules cannot see webfetch args today, so a patterned - 9479
// allow must NOT suppress the injected Ask: matching URLs still ask - 9480
// (severity Ask > Allow) and non-matching ones fall through to the - 9481
// same Ask. Failing toward asking is the safe direction. - 9482
let mut cfg = vak_config::Config::default(); - 9483
cfg.allow.push("webfetch(example.com/*)".into()); - 9484
let dir = tempfile::tempdir().unwrap(); - 9485
let e = build_engine_with(&cfg, &[]).unwrap(); - 9486
for url in ["other.org/x", "example.com/x"] { - 9487
let d = e.evaluate( - 9488
"webfetch", - 9489
&serde_json::json!({"url": url}), - 9490
Mode::WorkspaceWrite, - 9491
dir.path(), - 9492
); - 9493
assert!(matches!(d, Decision::Ask { .. }), "{url}: {d:?}"); - 9494
} - 9495
} - 9496
- 9497
#[test] - 9498
fn other_tools_keep_their_existing_defaults() { - 9499
// The seam must not widen: bash still asks under workspace-write, - 9500
// session_search stays a read tool, remember stays sanctioned. - 9501
let dir = tempfile::tempdir().unwrap(); - 9502
let e = build_engine_with(&vak_config::Config::default(), &[]).unwrap(); - 9503
let bash = e.evaluate( - 9504
"bash", - 9505
&serde_json::json!({"command": "ls"}), - 9506
Mode::WorkspaceWrite, - 9507
dir.path(), - 9508
); - 9509
assert!(matches!(bash, Decision::Ask { .. })); - 9510
for tool in ["session_search", "remember", "propose_skill"] { - 9511
let d = e.evaluate( - 9512
tool, - 9513
&serde_json::json!({}), - 9514
Mode::WorkspaceWrite, - 9515
dir.path(), - 9516
); - 9517
assert!(matches!(d, Decision::Allow), "{tool}: {d:?}"); - 9518
} - 9519
} - 9520
- 9521
/// The regression the synthetic `?webfetch` injection caused: a network - 9522
/// tool's restricted-mode gate must be a MODE default, so that - 9523
/// `approval_mode = "auto-approve"` reaches it exactly as it reaches - 9524
/// bash and every other mode-gated tool. When the gate was an injected - 9525
/// rule, `auto_approve` refused it — the desktop kept prompting for - 9526
/// webfetch with auto-approve on, and only for webfetch. - 9527
#[test] - 9528
fn network_tools_gate_as_a_mode_default_so_auto_approve_reaches_them() { - 9529
let dir = tempfile::tempdir().unwrap(); - 9530
let cfg = vak_config::Config::default(); - 9531
for mode in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 9532
let engine = build_engine_with(&cfg, &[]).unwrap(); - 9533
for tool in NETWORK_TOOLS { - 9534
let decision = - 9535
engine.evaluate(tool, &serde_json::json!({}), to_mode(mode), dir.path()); - 9536
let Decision::Ask { source, .. } = decision else { - 9537
panic!("{tool} in {mode:?} must Ask, got {decision:?}"); - 9538
}; - 9539
assert_eq!( - 9540
source, - 9541
vak_permission::AskSource::ModeDefault, - 9542
"{tool} in {mode:?} must ask as a mode default, not as a rule" - 9543
); - 9544
assert!( - 9545
vak_agent::auto_approve( - 9546
vak_agent::ApprovalMode::AutoApprove, - 9547
source, - 9548
tool, - 9549
&serde_json::json!({}), - 9550
to_mode(mode), - 9551
false, - 9552
dir.path(), - 9553
), - 9554
"{tool} in {mode:?} must be reachable under auto-approve" - 9555
); - 9556
} - 9557
} - 9558
} - 9559
- 9560
/// An operator's own `?webfetch` is still a rule, and still outranks - 9561
/// auto-approve. Removing the injection must not remove that. - 9562
#[test] - 9563
fn a_deliberate_ask_rule_still_outranks_auto_approve() { - 9564
let dir = tempfile::tempdir().unwrap(); - 9565
let mut cfg = vak_config::Config::default(); - 9566
cfg.ask.push("?webfetch".into()); - 9567
let engine = build_engine_with(&cfg, &[]).unwrap(); - 9568
let decision = engine.evaluate( - 9569
"webfetch", - 9570
&serde_json::json!({}), - 9571
Mode::WorkspaceWrite, - 9572
dir.path(), - 9573
); - 9574
let Decision::Ask { source, .. } = decision else { - 9575
panic!("expected Ask, got {decision:?}"); - 9576
}; - 9577
assert_eq!(source, vak_permission::AskSource::Rule); - 9578
assert!(!vak_agent::auto_approve( - 9579
vak_agent::ApprovalMode::AutoApprove, - 9580
source, - 9581
"webfetch", - 9582
&serde_json::json!({}), - 9583
Mode::WorkspaceWrite, - 9584
false, - 9585
dir.path(), - 9586
)); - 9587
} - 9588
} - 9589
- 9590
/// Configured capability reconciled against reachable capability. - 9591
/// - 9592
/// The scenario every test here is built from is a real one: a Telegram - 9593
/// turn, `permission_mode = "workspace-write"`, one MCP server (`tavily`) - 9594
/// configured with a valid key, and `gateway.approvals` left at its `deny` - 9595
/// default. Discovery worked perfectly — the model listed the catalogue and - 9596
/// called `tavily_search` with correct arguments on its first attempt — and - 9597
/// every call was refused by an approver that was never going to say yes. - 9598
/// It burned four tool calls, then told the user it had no way to search - 9599
/// the web. Nothing was broken. Nothing was logged. The prompt had promised - 9600
/// a capability the composed policy would never permit. - 9601
#[cfg(test)] - 9602
mod capability_reach_tests { - 9603
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 9604
- 9605
use super::*; - 9606
use crate::reach::Reach; - 9607
use vak_permission::{Decision, Mode}; - 9608
- 9609
/// A workspace with one MCP server and an explicit permission mode. - 9610
/// - 9611
/// The data home is pinned first. `global_path()` resolves to - 9612
/// `default_workspace()/.vak/config.toml` — a developer's real - 9613
/// `~/vak-home` config — and it merges UNDER this tempdir's project - 9614
/// layer. Without the pin, an operator who sets `full-access` on their - 9615
/// own install turns every `Gated`/`Blocked` expectation here into - 9616
/// `Open`, and the suite fails on their machine only. That is exactly - 9617
/// what happened. - 9618
fn workspace(extra: &str) -> tempfile::TempDir { - 9619
vak_config::paths::isolate_home_for_tests(); - 9620
let dir = tempfile::tempdir().unwrap(); - 9621
let vak = dir.path().join(".vak"); - 9622
std::fs::create_dir_all(&vak).unwrap(); - 9623
std::fs::write( - 9624
vak.join("config.toml"), - 9625
format!( - 9626
"permission_mode = \"workspace-write\"\n\ - 9627
{extra}\n\ - 9628
[mcp.servers.tavily]\n\ - 9629
command = \"npx\"\n\ - 9630
args = [\"-y\", \"tavily-mcp@latest\"]\n\ - 9631
network = true\n" - 9632
), - 9633
) - 9634
.unwrap(); - 9635
dir - 9636
} - 9637
- 9638
fn core_for(dir: &tempfile::TempDir, answerable: bool) -> Core { - 9639
let core = Core::new_with_trust(dir.path().to_path_buf(), true) - 9640
.unwrap() - 9641
.with_approver_answerable(answerable); - 9642
core.set_sessions_home(dir.path().join("home")); - 9643
core - 9644
} - 9645
- 9646
fn standing_for<'a>(standings: &'a [reach::Standing], label: &str) -> &'a reach::Standing { - 9647
standings - 9648
.iter() - 9649
.find(|standing| standing.label == label) - 9650
.unwrap_or_else(|| panic!("no standing for {label} in {standings:?}")) - 9651
} - 9652
- 9653
/// The bug, stated as a test: an unattended surface must not be told it - 9654
/// has a capability whose every use it will refuse. - 9655
#[test] - 9656
fn an_unattended_surface_does_not_advertise_a_capability_it_will_refuse() { - 9657
let dir = workspace(""); - 9658
let core = core_for(&dir, false); - 9659
- 9660
let standings = core.capability_standings(); - 9661
assert_eq!( - 9662
standing_for(&standings, "mcp server `tavily`").reach, - 9663
Reach::Blocked - 9664
); - 9665
- 9666
let prompt = core.system_prompt(); - 9667
// It is no longer offered as usable... - 9668
assert!( - 9669
!prompt.contains("\n- tavily\n"), - 9670
"unreachable server still advertised as usable:\n{prompt}" - 9671
); - 9672
// ...but it is not silently erased either: the model is told it - 9673
// exists, that it cannot be used, and what would fix it, so it can - 9674
// answer the user instead of hunting for a substitute tool. - 9675
assert!(prompt.contains("Configured but NOT usable on this turn")); - 9676
assert!(prompt.contains("mcp server `tavily`")); - 9677
assert!(prompt.contains("no approver to answer it")); - 9678
assert!(prompt.contains("approvals = \"forward\"")); - 9679
} - 9680
- 9681
/// The same workspace on an attended surface is unchanged: a gate - 9682
/// somebody can answer is a working capability, and still advertised. - 9683
#[test] - 9684
fn an_attended_surface_still_advertises_a_gated_capability() { - 9685
let dir = workspace(""); - 9686
let core = core_for(&dir, true); - 9687
- 9688
assert_eq!( - 9689
standing_for(&core.capability_standings(), "mcp server `tavily`").reach, - 9690
Reach::Gated - 9691
); - 9692
let prompt = core.system_prompt(); - 9693
assert!(prompt.contains("\n- tavily\n")); - 9694
assert!(!prompt.contains("Configured but NOT usable")); - 9695
} - 9696
- 9697
/// And the operator's actual fix works: allowing the tool outright - 9698
/// removes the gate, so even the unattended surface can use it. - 9699
/// - 9700
/// Note what stays blocked. `webfetch` and `browse` still gate on an - 9701
/// approval this surface cannot answer, so they are still reported — - 9702
/// which is correct, and is the second half of the same transcript: - 9703
/// after the MCP denials the model fell back to `webfetch` and was - 9704
/// refused there too. Reachability is per capability, not per turn. - 9705
#[test] - 9706
fn allowing_the_tool_makes_it_reachable_unattended() { - 9707
let dir = workspace("allow = [\"mcp\"]"); - 9708
let core = core_for(&dir, false); - 9709
- 9710
let standings = core.capability_standings(); - 9711
assert_eq!( - 9712
standing_for(&standings, "mcp server `tavily`").reach, - 9713
Reach::Open - 9714
); - 9715
let prompt = core.system_prompt(); - 9716
assert!(prompt.contains("\n- tavily\n")); - 9717
assert!( - 9718
!reach::prompt_section(&standings).contains("tavily"), - 9719
"a reachable server must not be listed as unusable" - 9720
); - 9721
// The network tools are a separate capability and still gated. - 9722
assert_eq!(standing_for(&standings, "`webfetch`").reach, Reach::Blocked); - 9723
} - 9724
- 9725
/// A deny is unreachable on every surface — no approver can answer a - 9726
/// `Deny`, so an attended surface must report it exactly as bluntly. - 9727
#[test] - 9728
fn a_denied_capability_is_unreachable_even_when_attended() { - 9729
let dir = workspace("deny = [\"-mcp\"]"); - 9730
let core = core_for(&dir, true); - 9731
- 9732
let standings = core.capability_standings(); - 9733
let standing = standing_for(&standings, "mcp server `tavily`"); - 9734
assert_eq!(standing.reach, Reach::Blocked); - 9735
assert!(standing.reason.contains("denied by rule")); - 9736
assert!(!core.system_prompt().contains("\n- tavily\n")); - 9737
} - 9738
- 9739
/// Uncertainty must degrade to `Gated`, never to `Blocked`. The probe - 9740
/// runs before a tool name exists, so a patterned rule cannot be - 9741
/// evaluated — and hiding a capability that would in fact have worked - 9742
/// is worse than advertising one that gates. - 9743
#[test] - 9744
fn a_patterned_rule_keeps_the_capability_advertised() { - 9745
let dir = workspace("allow = [\"mcp(tavily/tavily_search)\"]"); - 9746
let core = core_for(&dir, false); - 9747
- 9748
assert_eq!( - 9749
standing_for(&core.capability_standings(), "mcp server `tavily`").reach, - 9750
Reach::Gated - 9751
); - 9752
assert!(core.system_prompt().contains("\n- tavily\n")); - 9753
} - 9754
- 9755
/// AGENTS.md invariant 20: a channel overlay is restrictive. Both call - 9756
/// sites used to compile `tools_allow` into blanket `+` allow rules, so - 9757
/// narrowing a chat to one tool handed that chat the tool with its - 9758
/// approval gate removed — an escalation performed by adding a - 9759
/// restriction. - 9760
#[test] - 9761
fn a_narrowing_channel_overlay_never_removes_an_approval_gate() { - 9762
let dir = workspace(""); - 9763
let core = core_for(&dir, true); - 9764
let cwd = core.cwd().clone(); - 9765
- 9766
let baseline = build_engine_with(core.config(), &core.channel_permission_rules()).unwrap(); - 9767
assert!(matches!( - 9768
baseline.evaluate( - 9769
"bash", - 9770
&serde_json::json!({"command": "ls"}), - 9771
Mode::WorkspaceWrite, - 9772
&cwd, - 9773
), - 9774
Decision::Ask { .. } - 9775
)); - 9776
- 9777
core.apply_channel_policy(vak_config::ChannelPolicy { - 9778
tools_allow: Some(vec!["bash".into()]), - 9779
mcp_allow: Some(vec!["tavily/tavily_search".into()]), - 9780
..Default::default() - 9781
}); - 9782
let narrowed = build_engine_with(core.config(), &core.channel_permission_rules()).unwrap(); - 9783
- 9784
for (tool, args) in [ - 9785
("bash", serde_json::json!({"command": "ls"})), - 9786
( - 9787
"mcp", - 9788
serde_json::json!({"action": "call", "server": "tavily", "tool": "tavily_search"}), - 9789
), - 9790
] { - 9791
let decision = narrowed.evaluate(tool, &args, Mode::WorkspaceWrite, &cwd); - 9792
assert!( - 9793
!matches!(decision, Decision::Allow), - 9794
"narrowing overlay granted {tool} an unattended Allow: {decision:?}" - 9795
); - 9796
} - 9797
} - 9798
- 9799
/// `Some([])` still means "block this category outright". - 9800
#[test] - 9801
fn an_empty_channel_allowlist_still_denies_the_category() { - 9802
let dir = workspace(""); - 9803
let core = core_for(&dir, true); - 9804
let cwd = core.cwd().clone(); - 9805
core.apply_channel_policy(vak_config::ChannelPolicy { - 9806
tools_allow: Some(Vec::new()), - 9807
mcp_allow: Some(Vec::new()), - 9808
..Default::default() - 9809
}); - 9810
let engine = build_engine_with(core.config(), &core.channel_permission_rules()).unwrap(); - 9811
for tool in ["bash", "read", "webfetch", "browse", "mcp"] { - 9812
let decision = - 9813
engine.evaluate(tool, &serde_json::json!({}), Mode::WorkspaceWrite, &cwd); - 9814
assert!( - 9815
matches!(decision, Decision::Deny { .. }), - 9816
"{tool} survived an empty channel allowlist: {decision:?}" - 9817
); - 9818
} - 9819
} - 9820
- 9821
/// One blocked MCP server must not take the broker down with it, and a - 9822
/// tool with no reachable use left must not stay in the registry. - 9823
#[test] - 9824
fn only_fully_blocked_tools_are_dropped() { - 9825
let standing = |kind: CapabilityKind, name: &str, reach: Reach| reach::Standing { - 9826
id: capability::CapabilityId::new(kind.clone(), name), - 9827
tool: if kind == CapabilityKind::McpServer { - 9828
"mcp".into() - 9829
} else { - 9830
name.into() - 9831
}, - 9832
label: name.into(), - 9833
reach, - 9834
reason: String::new(), - 9835
remedy: String::new(), - 9836
}; - 9837
let server = |name: &str, reach| standing(CapabilityKind::McpServer, name, reach); - 9838
- 9839
let mixed = vec![ - 9840
server("a", Reach::Blocked), - 9841
server("b", Reach::Open), - 9842
standing(CapabilityKind::Tool, "webfetch", Reach::Blocked), - 9843
]; - 9844
assert_eq!(reach::fully_blocked_tools(&mixed), vec!["webfetch"]); - 9845
assert_eq!(reach::blocked_mcp_servers(&mixed), vec!["a"]); - 9846
- 9847
let all = vec![server("a", Reach::Blocked), server("b", Reach::Blocked)]; - 9848
assert_eq!(reach::fully_blocked_tools(&all), vec!["mcp"]); - 9849
} - 9850
- 9851
#[test] - 9852
fn denied_skill_is_blocked_and_dropped_from_descriptors() { - 9853
let dir = workspace("deny = [\"-skill(blocked-skill)\"]"); - 9854
let skill_dir = dir.path().join(".vak/skills/blocked-skill"); - 9855
std::fs::create_dir_all(&skill_dir).unwrap(); - 9856
std::fs::write( - 9857
skill_dir.join("SKILL.md"), - 9858
"---\nname: blocked-skill\ndescription: test blocked skill\n---\nbody", - 9859
) - 9860
.unwrap(); - 9861
- 9862
let core = core_for(&dir, true); - 9863
let standings = core.capability_standings(); - 9864
let standing = standing_for(&standings, "skill `blocked-skill`"); - 9865
assert_eq!(standing.reach, Reach::Blocked); - 9866
- 9867
let descriptors = core.capability_descriptors(); - 9868
assert!( - 9869
!descriptors.iter().any(|d| d.name == "blocked-skill"), - 9870
"blocked skill must not be in admitted capability descriptors" - 9871
); - 9872
- 9873
let prompt = core.system_prompt(); - 9874
assert!( - 9875
prompt.contains("skill `blocked-skill`"), - 9876
"prompt should report blocked skill in standing section" - 9877
); - 9878
} - 9879
- 9880
#[test] - 9881
fn capability_diagnostics_reports_parse_failure_and_prompt_includes_it() { - 9882
let dir = workspace(""); - 9883
let bad_skill_dir = dir.path().join(".vak/skills/Bad_Skill"); - 9884
std::fs::create_dir_all(&bad_skill_dir).unwrap(); - 9885
std::fs::write( - 9886
bad_skill_dir.join("SKILL.md"), - 9887
"---\nname: Bad_Skill\ndescription: bad format\n---\nbody", - 9888
) - 9889
.unwrap(); - 9890
- 9891
let core = core_for(&dir, true); - 9892
let diags = core.capability_diagnostics(); - 9893
let bad_diag = diags.iter().find(|d| d.name == "Bad_Skill"); - 9894
assert!(bad_diag.is_some(), "diagnostic must report Bad_Skill"); - 9895
assert!(bad_diag.unwrap().reason.contains("lowercase kebab-case")); - 9896
- 9897
let prompt = core.system_prompt(); - 9898
assert!(prompt.contains("skill `Bad_Skill`")); - 9899
assert!(prompt.contains("Fix: fix the SKILL.md frontmatter")); - 9900
} - 9901
- 9902
#[test] - 9903
fn tools_and_commitment_overrides_are_dynamic() { - 9904
let dir = workspace(""); - 9905
let core = core_for(&dir, true); - 9906
- 9907
// web_fetch and browse default to true - 9908
assert!(core.effective_web_fetch()); - 9909
assert!(core.effective_browse()); - 9910
assert!(core.tool_names().contains(&"webfetch".to_string())); - 9911
assert!(core.tool_names().contains(&"browse".to_string())); - 9912
- 9913
// Apply dynamic tool toggle: disable both - 9914
core.apply_persisted_tools(false, false); - 9915
assert!(!core.effective_web_fetch()); - 9916
assert!(!core.effective_browse()); - 9917
assert!(!core.tool_names().contains(&"webfetch".to_string())); - 9918
assert!(!core.tool_names().contains(&"browse".to_string())); - 9919
- 9920
// Toggle back - 9921
core.apply_persisted_tools(true, true); - 9922
assert!(core.effective_web_fetch()); - 9923
assert!(core.effective_browse()); - 9924
assert!(core.tool_names().contains(&"webfetch".to_string())); - 9925
assert!(core.tool_names().contains(&"browse".to_string())); - 9926
- 9927
// Toggle commitment - 9928
core.apply_persisted_commitment(true); - 9929
assert!(core.effective_commitment()); - 9930
assert!(core.tool_names().contains(&"commitments".to_string())); - 9931
- 9932
core.apply_persisted_commitment(false); - 9933
assert!(!core.effective_commitment()); - 9934
assert!(!core.tool_names().contains(&"commitments".to_string())); - 9935
} - 9936
- 9937
#[tokio::test] - 9938
async fn permission_mode_change_cancels_existing_permission_lease() { - 9939
let dir = tempfile::tempdir().unwrap(); - 9940
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 9941
let lease = core.permission_lease(); - 9942
core.set_permission_mode(vak_config::PermissionMode::ReadOnly); - 9943
assert!(lease.is_cancelled()); - 9944
assert!(!core.permission_lease().is_cancelled()); - 9945
} - 9946
} - 9947
- 9948
/// Every `Core` accessor must terminate while session-scoped overrides are - 9949
/// set. `cache_home` once locked `sessions_home_override` and then called - 9950
/// `sessions_home()`, which locks the same non-reentrant mutex — the - 9951
/// thread wedged forever. It surfaced only in tests, because production - 9952
/// leaves the override unset and never entered the branch, and it made - 9953
/// `cargo test --workspace` hang rather than fail. - 9954
/// - 9955
/// These run each accessor on a worker thread with a deadline, so a - 9956
/// reintroduced deadlock fails the suite instead of hanging it. A test - 9957
/// that hangs reports nothing and blocks every gate behind it. - 9958
#[cfg(test)] - 9959
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 9960
mod override_deadlock { - 9961
use super::*; - 9962
- 9963
/// Run `f` on its own thread, failing if it has not returned in time. - 9964
fn within<T: Send + 'static>(label: &str, f: impl FnOnce() -> T + Send + 'static) -> T { - 9965
let (tx, rx) = std::sync::mpsc::channel(); - 9966
let handle = std::thread::spawn(move || { - 9967
let out = f(); - 9968
// Send may fail if the receiver already gave up; the timeout - 9969
// below is what reports that, so ignore the error here. - 9970
let _ = tx.send(()); - 9971
out - 9972
}); - 9973
match rx.recv_timeout(std::time::Duration::from_secs(10)) { - 9974
Ok(()) => handle.join().expect("accessor thread panicked"), - 9975
Err(_) => panic!( - 9976
"{label} did not return within 10s — an accessor is deadlocked \ - 9977
(a guard held across a call that re-locks the same mutex)" - 9978
), - 9979
} - 9980
} - 9981
- 9982
/// One accessor to exercise, boxed so a heterogeneous set can share - 9983
/// a list. - 9984
type AccessorCheck = Box<dyn FnOnce(Arc<Core>) + Send>; - 9985
- 9986
fn core_with_override() -> (tempfile::TempDir, Core) { - 9987
let dir = tempfile::tempdir().unwrap(); - 9988
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 9989
core.set_sessions_home(dir.path().join("home")); - 9990
(dir, core) - 9991
} - 9992
- 9993
#[test] - 9994
fn cache_home_returns_while_the_sessions_home_override_is_set() { - 9995
let (dir, core) = core_with_override(); - 9996
let expected = dir.path().join("home").join("cache"); - 9997
let got = within("cache_home", move || core.cache_home()); - 9998
assert_eq!( - 9999
got, expected, - 10000
"an overridden home is a self-contained sandbox" - 10001
); - 10002
} - 10003
- 10004
#[test] - 10005
fn cache_home_falls_back_to_the_platform_directory_without_an_override() { - 10006
let dir = tempfile::tempdir().unwrap(); - 10007
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 10008
let got = within("cache_home (no override)", move || core.cache_home()); - 10009
assert_eq!(got, vak_config::paths::cache_home()); - 10010
} - 10011
- 10012
#[test] - 10013
fn every_override_backed_accessor_terminates() { - 10014
// Breadth matters more than depth here: the hazard is the - 10015
// locking idiom, so each accessor that reads an override is - 10016
// exercised with one set. - 10017
let (_dir, core) = core_with_override(); - 10018
let core = Arc::new(core); - 10019
core.set_model("m".into()); - 10020
core.set_provider("p".into()); - 10021
core.set_permission_mode(vak_config::PermissionMode::FullAccess); - 10022
core.set_sandbox_backend(Some("os".into())); - 10023
- 10024
let checks: Vec<(&str, AccessorCheck)> = vec![ - 10025
( - 10026
"sessions_home", - 10027
Box::new(|c: Arc<Core>| { - 10028
c.sessions_home(); - 10029
}), - 10030
), - 10031
( - 10032
"cache_home", - 10033
Box::new(|c: Arc<Core>| { - 10034
c.cache_home(); - 10035
}), - 10036
), - 10037
( - 10038
"effective_model", - 10039
Box::new(|c: Arc<Core>| { - 10040
c.effective_model(); - 10041
}), - 10042
), - 10043
( - 10044
"effective_provider", - 10045
Box::new(|c: Arc<Core>| { - 10046
c.effective_provider(); - 10047
}), - 10048
), - 10049
( - 10050
"effective_max_turns", - 10051
Box::new(|c: Arc<Core>| { - 10052
c.effective_max_turns(); - 10053
}), - 10054
), - 10055
( - 10056
"effective_theme", - 10057
Box::new(|c: Arc<Core>| { - 10058
c.effective_theme(); - 10059
}), - 10060
), - 10061
( - 10062
"effective_permission_mode", - 10063
Box::new(|c: Arc<Core>| { - 10064
c.effective_permission_mode(); - 10065
}), - 10066
), - 10067
( - 10068
"effective_sandbox_backend", - 10069
Box::new(|c: Arc<Core>| { - 10070
c.effective_sandbox_backend(); - 10071
}), - 10072
), - 10073
]; - 10074
for (label, check) in checks { - 10075
let c = Arc::clone(&core); - 10076
within(label, move || check(c)); - 10077
} - 10078
} - 10079
} - 10080
- 10081
#[cfg(test)] - 10082
#[allow(clippy::unwrap_used, clippy::expect_used)] - 10083
mod route_control_tests { - 10084
use super::*; - 10085
- 10086
#[test] - 10087
fn provider_and_model_are_never_observed_as_a_torn_pair() { - 10088
let dir = tempfile::tempdir().unwrap(); - 10089
let core = Arc::new(Core::new(dir.path().to_path_buf()).unwrap()); - 10090
core.set_route("provider-a".into(), "model-a".into()); - 10091
let writer = { - 10092
let core = Arc::clone(&core); - 10093
std::thread::spawn(move || { - 10094
for _ in 0..10_000 { - 10095
core.set_route("provider-b".into(), "model-b".into()); - 10096
core.set_route("provider-a".into(), "model-a".into()); - 10097
} - 10098
}) - 10099
}; - 10100
for _ in 0..20_000 { - 10101
let route = core.effective_route(); - 10102
assert!( - 10103
(route.provider == "provider-a" && route.model == "model-a") - 10104
|| (route.provider == "provider-b" && route.model == "model-b") - 10105
); - 10106
} - 10107
writer.join().unwrap(); - 10108
} - 10109
- 10110
#[test] - 10111
fn independent_cores_observe_one_persisted_route_change() { - 10112
let dir = tempfile::tempdir().unwrap(); - 10113
let first = Core::new(dir.path().to_path_buf()).unwrap(); - 10114
let second = Core::new(dir.path().to_path_buf()).unwrap(); - 10115
vak_config::persist_project_preferences( - 10116
dir.path(), - 10117
Some("provider-new"), - 10118
Some("model-new"), - 10119
None, - 10120
None, - 10121
None, - 10122
None, - 10123
) - 10124
.unwrap(); - 10125
first.refresh_persisted_route().unwrap(); - 10126
second.refresh_persisted_route().unwrap(); - 10127
assert_eq!(first.effective_provider(), "provider-new"); - 10128
assert_eq!(first.effective_model(), "model-new"); - 10129
assert_eq!(first.effective_route(), second.effective_route()); - 10130
} - 10131
- 10132
#[test] - 10133
fn persisted_non_route_preferences_refresh_without_touching_runtime_pins() { - 10134
let dir = tempfile::tempdir().unwrap(); - 10135
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 10136
vak_config::persist_project_preferences( - 10137
dir.path(), - 10138
None, - 10139
None, - 10140
Some(17), - 10141
None, - 10142
None, - 10143
Some("plain"), - 10144
) - 10145
.unwrap(); - 10146
core.refresh_persisted_preferences().unwrap(); - 10147
assert_eq!(core.effective_max_turns(), 17); - 10148
assert_eq!(core.effective_theme(), "plain"); - 10149
- 10150
core.set_max_turns(23); - 10151
vak_config::persist_project_preferences(dir.path(), None, None, Some(31), None, None, None) - 10152
.unwrap(); - 10153
core.refresh_persisted_preferences().unwrap(); - 10154
assert_eq!(core.effective_max_turns(), 23, "scoped runtime pin wins"); - 10155
} - 10156
- 10157
#[tokio::test] - 10158
async fn explicit_session_route_does_not_mutate_the_shared_default() { - 10159
let dir = tempfile::tempdir().unwrap(); - 10160
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 10161
core.set_sessions_home(dir.path().join("home")); - 10162
let default = core.effective_route(); - 10163
let session = core - 10164
.start_session_with_route("channel-provider".into(), "channel-model".into()) - 10165
.await - 10166
.unwrap(); - 10167
let contract = &session.header().unwrap().contract; - 10168
assert_eq!(contract.provider, "channel-provider"); - 10169
assert_eq!(contract.model, "channel-model"); - 10170
assert_eq!(core.effective_route(), default); - 10171
} - 10172
- 10173
#[tokio::test] - 10174
async fn every_new_core_session_gets_local_conversation_admission() { - 10175
let dir = tempfile::tempdir().unwrap(); - 10176
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 10177
core.set_sessions_home(dir.path().join("home")); - 10178
let session = core.start_session().await.unwrap(); - 10179
let header = session.header().unwrap(); - 10180
let context = header - 10181
.conversation - 10182
.as_ref() - 10183
.expect("conversation admission"); - 10184
assert_eq!(context.conversation_id, header.session_id); - 10185
assert_eq!(context.audience_id, "local"); - 10186
assert_eq!( - 10187
context - 10188
.origin - 10189
.as_ref() - 10190
.map(|origin| origin.surface.as_str()), - 10191
Some("local") - 10192
); - 10193
assert_eq!( - 10194
header.agent.as_ref().map(|agent| agent.id.as_str()), - 10195
Some("vak") - 10196
); - 10197
} - 10198
} - 10199
- 10200
#[cfg(test)] - 10201
#[allow(clippy::unwrap_used, clippy::expect_used)] - 10202
mod spend_gate_persistence_tests { - 10203
// Regression coverage for the FinOps audit fix: `spend_gate_for` used - 10204
// to be `finops::CoreSpendGate::new(..)` called fresh inline on every - 10205
// turn, so `max_run_usd`'s in-memory spend counter reset to zero each - 10206
// message — a multi-turn conversation could blow past the run cap by - 10207
// an arbitrary multiple. `spend_gate_for` now caches one gate per - 10208
// session and reuses it turn over turn. - 10209
use super::Core; - 10210
use std::sync::Arc; - 10211
use vak_agent::{SpendCheck, SpendGate}; - 10212
use vak_llm::Usage; - 10213
- 10214
fn core_with_run_cap(dir: &std::path::Path, cap: f64) -> Core { - 10215
let core = Core::new(dir.join("cwd")).unwrap(); - 10216
core.set_sessions_home(dir.join("home")); - 10217
core.apply_persisted_finops_caps(Some(Some(cap)), None); - 10218
core - 10219
} - 10220
- 10221
fn check(session_id: &'static str) -> SpendCheck<'static> { - 10222
SpendCheck { - 10223
model: "claude-sonnet", - 10224
provider: "anthropic", - 10225
session_id, - 10226
est_input_tokens: 1_000_000, - 10227
planned_output_tokens: 100_000, - 10228
} - 10229
} - 10230
- 10231
fn usage_1m_in_100k_out() -> Usage { - 10232
Usage { - 10233
input_tokens: 1_000_000, - 10234
output_tokens: 100_000, - 10235
..Default::default() - 10236
} - 10237
} - 10238
- 10239
#[test] - 10240
fn same_session_reuses_one_gate_across_calls() { - 10241
let dir = tempfile::tempdir().unwrap(); - 10242
let core = core_with_run_cap(dir.path(), 5.0); - 10243
- 10244
let turn1 = core.spend_gate_for("s1"); - 10245
let turn2 = core.spend_gate_for("s1"); - 10246
assert!( - 10247
Arc::ptr_eq(&turn1, &turn2), - 10248
"the same session must get the SAME gate on its next turn, \ - 10249
not a freshly zeroed one" - 10250
); - 10251
- 10252
let other_session = core.spend_gate_for("s2"); - 10253
assert!( - 10254
!Arc::ptr_eq(&turn1, &other_session), - 10255
"a different session must not share another session's run budget" - 10256
); - 10257
} - 10258
- 10259
#[tokio::test] - 10260
async fn run_cap_spend_survives_across_simulated_turns() { - 10261
// sonnet: $3/MTok in, $15/MTok out => 1M in + 100k out = $4.50. - 10262
let dir = tempfile::tempdir().unwrap(); - 10263
let core = core_with_run_cap(dir.path(), 5.0); - 10264
- 10265
// Turn 1: `run_turn_inner` fetches the session's gate, admits the - 10266
// dispatch, then records it settled. - 10267
let gate = core.spend_gate_for("s1"); - 10268
gate.authorize(&check("s1")).await.unwrap(); - 10269
gate.record_settled("anthropic", "claude-sonnet", "s1", &usage_1m_in_100k_out()); - 10270
- 10271
// Turn 2: a SEPARATE `run_turn_inner` call for the same session - 10272
// re-fetches the gate. Before this fix, that call built a brand - 10273
// new `CoreSpendGate` with `run_spent_usd` back at zero, so this - 10274
// dispatch was wrongly admitted even though the run cap was - 10275
// already exhausted by turn 1. - 10276
let gate_next_turn = core.spend_gate_for("s1"); - 10277
let err = gate_next_turn - 10278
.authorize(&check("s1")) - 10279
.await - 10280
.expect_err("run cap must still reflect turn 1's spend on turn 2"); - 10281
assert!(err.contains("run budget $5.00"), "{err}"); - 10282
} - 10283
- 10284
#[test] - 10285
fn forget_spend_gate_drops_cached_state() { - 10286
let dir = tempfile::tempdir().unwrap(); - 10287
let core = core_with_run_cap(dir.path(), 5.0); - 10288
- 10289
let before = core.spend_gate_for("s1"); - 10290
core.forget_spend_gate("s1"); - 10291
let after = core.spend_gate_for("s1"); - 10292
assert!( - 10293
!Arc::ptr_eq(&before, &after), - 10294
"forgetting a session's gate must not leave the old one cached" - 10295
); - 10296
- 10297
// Forgetting a session with no cached gate must be a harmless no-op. - 10298
core.forget_spend_gate("never-seen"); - 10299
} - 10300
- 10301
#[test] - 10302
fn builtin_domain_roles_admitted_in_role_prompts() { - 10303
let dir = tempfile::tempdir().unwrap(); - 10304
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 10305
let roles = core.prompt_role_names(); - 10306
assert!(roles.contains(&"analyst".to_string())); - 10307
assert!(roles.contains(&"operator".to_string())); - 10308
assert!(roles.contains(&"researcher".to_string())); - 10309
assert!(roles.contains(&"writer".to_string())); - 10310
- 10311
let prompts = core.role_prompts(&[]); - 10312
assert!(prompts.contains_key("analyst")); - 10313
assert!(prompts.contains_key("operator")); - 10314
assert!(prompts.contains_key("researcher")); - 10315
assert!(prompts.contains_key("writer")); - 10316
assert!(prompts["analyst"].contains("Data Analyst")); - 10317
assert!(prompts["researcher"].contains("Research Analyst")); - 10318
} - 10319
- 10320
#[test] - 10321
fn agent_scoped_secret_takes_precedence_over_shared_env() { - 10322
let dir = tempfile::tempdir().unwrap(); - 10323
let home = tempfile::tempdir().unwrap(); - 10324
let mut core = Core::new(dir.path().to_path_buf()).unwrap(); - 10325
core.set_sessions_home(home.path().to_path_buf()); - 10326
core.set_user_env_path(home.path().join(".env")); - 10327
- 10328
vak_config::upsert_env_file( - 10329
&home.path().join(".env"), - 10330
"ANTHROPIC_API_KEY", - 10331
"shared-anthropic-key", - 10332
) - 10333
.unwrap(); - 10334
- 10335
let agent = vak_session::types::AgentIdentity { - 10336
id: "specialist".into(), - 10337
revision: 1, - 10338
name: "Specialist".into(), - 10339
character: "vak".into(), - 10340
personality: "Focused".into(), - 10341
animation: "subtle".into(), - 10342
voice: "default".into(), - 10343
behaviour: "Analytical".into(), - 10344
responsibilities: "Auditing".into(), - 10345
instructions: "Audit carefully".into(), - 10346
}; - 10347
core = core.with_agent_identity(Some(agent)); - 10348
- 10349
assert_eq!( - 10350
core.provider_secret("ANTHROPIC_API_KEY"), - 10351
Some("shared-anthropic-key".into()) - 10352
); - 10353
- 10354
let agent_home = core.sessions_home(); - 10355
vak_config::upsert_env_file( - 10356
&agent_home.join(".env"), - 10357
"ANTHROPIC_API_KEY", - 10358
"agent-private-key", - 10359
) - 10360
.unwrap(); - 10361
- 10362
assert_eq!( - 10363
core.provider_secret("ANTHROPIC_API_KEY"), - 10364
Some("agent-private-key".into()) - 10365
); - 10366
} - 10367
} - 10368
- 10369
/// docs/design/68-context-engine.md §1: a loopback ("ollama") leg is always - 10370
/// probed in full, a hosted leg is not unless `[probe] hosted = "full"`. - 10371
/// Uses the "Scripted"/`Fn`-provider pattern already used throughout - 10372
/// `vak-agent`'s tests (e.g. `crates/vak-agent/tests/doom_loop.rs`): a fake - 10373
/// `Provider` registered directly into the registry, no real network. The - 10374
/// hosted comparison leg names a provider `provider_auth_for` does not - 10375
/// recognize, so its auth resolution fails deterministically regardless of - 10376
/// what real credentials happen to be set in the developer's environment — - 10377
/// the point being tested is "no probe without opt-in", not credential - 10378
/// plumbing. - 10379
#[cfg(test)] - 10380
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 10381
mod capacity_probe_tests { - 10382
use super::Core; - 10383
use std::sync::Arc; - 10384
use tokio_util::sync::CancellationToken; - 10385
use vak_llm::{ - 10386
AssistantMessage, ChatRequest, ContentBlock, LlmError, Provider, StopReason, Usage, - 10387
}; - 10388
use vak_session::SessionLog; - 10389
use vak_session::types::{FrozenContract, SessionHeader}; - 10390
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.