- 8812
// user statement. - 8813
let control_entries: std::collections::HashSet<String> = session - 8814
.derive_transcript() - 8815
.into_iter() - 8816
.filter(|item| item.control.is_some()) - 8817
.map(|item| item.entry_id) - 8818
.collect(); - 8819
let mut tail = String::new(); - 8820
for (entry_id, m) in session.message_chain() { - 8821
if control_entries.contains(&entry_id) { - 8822
continue; - 8823
} - 8824
tail.push_str(&reflection::render_message(m.role, &m.content)); - 8825
} - 8826
if !final_text.is_empty() { - 8827
let block = vak_llm::ContentBlock::text(final_text.to_string()); - 8828
tail.push_str(&reflection::render_message( - 8829
vak_llm::Role::Assistant, - 8830
&[block], - 8831
)); - 8832
} - 8833
- 8834
// Budget admission before any dispatch — the same CoreSpendGate - 8835
// path that admits run turns, so caps bind identically here. - 8836
let f = self.effective_finops(); - 8837
if f.max_run_usd.is_some() || f.max_day_usd.is_some() || !f.price_overrides.is_empty() { - 8838
// The session's own gate, not a fresh one — so a reflection - 8839
// pass is admitted against the SAME run/day budget the - 8840
// session's turns have already been spending from. - 8841
let gate = self.spend_gate_for(&sid); - 8842
let probe = vak_llm::Message { - 8843
role: vak_llm::Role::User, - 8844
content: vec![vak_llm::ContentBlock::text(tail.clone())], - 8845
}; - 8846
let est_profile = vak_context::capacity::CapacityProfile::from_metadata_only( - 8847
self.inner.config.context_window, - 8848
u64::from(self.inner.config.max_tokens), - 8849
"reflection-estimate".to_string(), - 8850
std::time::SystemTime::now(), - 8851
); - 8852
let est = est_profile.estimate_tokens( - 8853
reflection::system_prompt().len() as u64 + probe.text_content().len() as u64, - 8854
); - 8855
let model = self.effective_model(); - 8856
let provider_name = self.effective_provider(); - 8857
let check = vak_agent::SpendCheck { - 8858
model: &model, - 8859
provider: &provider_name, - 8860
session_id: &sid, - 8861
est_input_tokens: est, - 8862
planned_output_tokens: u64::from(reflection::MAX_TOKENS), - 8863
}; - 8864
if vak_agent::SpendGate::authorize(gate.as_ref(), &check) - 8865
.await - 8866
.is_err() - 8867
{ - 8868
return reflection::ReflectionOutcome::Skipped { reason: "budget" }; - 8869
} - 8870
} - 8871
- 8872
let model = self.effective_model(); - 8873
let proposals = - 8874
match reflection::propose(provider, &model, &tail, CancellationToken::new()).await { - 8875
Ok(p) => p, - 8876
Err(_) => { - 8877
return reflection::ReflectionOutcome::Skipped { - 8878
reason: "reflect-call-failed", - 8879
}; - 8880
} - 8881
}; - 8882
if proposals.notes.is_empty() && proposals.skill.is_none() { - 8883
return reflection::ReflectionOutcome::Reflected { - 8884
notes_added: 0, - 8885
skills_proposed: false, - 8886
}; - 8887
} - 8888
let home = self.sessions_home(); - 8889
let mut proposals = proposals; - 8890
if !self.channel_tool_allowed("propose_skill") { - 8891
proposals.skill = None; - 8892
} - 8893
match reflection::apply(home.as_path(), self.cwd(), &sid, &proposals) { - 8894
Ok((notes_added, skills_proposed)) => reflection::ReflectionOutcome::Reflected { - 8895
notes_added, - 8896
skills_proposed, - 8897
}, - 8898
Err(_) => reflection::ReflectionOutcome::Skipped { - 8899
reason: "apply-failed", - 8900
}, - 8901
} - 8902
} - 8903
- 8904
/// Run self-supervised memory consolidation across episodic notes: - 8905
/// promotes recurring procedures into immutable invariants, detects conflicts, - 8906
/// and distills structured entity records. - 8907
pub fn consolidate_memory(&self) -> Result<consolidation::ConsolidationReport, String> { - 8908
consolidation::consolidate_memory(&self.sessions_home(), self.cwd()) - 8909
} - 8910
} - 8911
- 8912
fn load_permissions_local(cwd: &std::path::Path) -> Vec<String> { - 8913
let Ok(text) = std::fs::read_to_string(cwd.join(PERMISSIONS_LOCAL_FILE)) else { - 8914
return Vec::new(); - 8915
}; - 8916
match toml::from_str::<PermissionsLocal>(&text) { - 8917
Ok(p) => p.allow, - 8918
Err(_) => Vec::new(), - 8919
} - 8920
} - 8921
- 8922
fn project_config_has_key(cwd: &std::path::Path, key: &str) -> bool { - 8923
std::fs::read_to_string(vak_config::project_path(cwd)) - 8924
.ok() - 8925
.and_then(|raw| toml::from_str::<toml::Value>(&raw).ok()) - 8926
.and_then(|value| value.as_table().map(|table| table.contains_key(key))) - 8927
.unwrap_or(false) - 8928
} - 8929
- 8930
fn global_config_has_key(key: &str) -> bool { - 8931
vak_config::global_path() - 8932
.and_then(|path| std::fs::read_to_string(path).ok()) - 8933
.and_then(|raw| toml::from_str::<toml::Value>(&raw).ok()) - 8934
.and_then(|value| value.as_table().map(|table| table.contains_key(key))) - 8935
.unwrap_or(false) - 8936
} - 8937
- 8938
fn project_profile_has_key(cwd: &std::path::Path, key: &str) -> bool { - 8939
config_profile_has_key(&vak_config::project_path(cwd), key) - 8940
} - 8941
- 8942
fn global_profile_has_key(key: &str) -> bool { - 8943
vak_config::global_path().is_some_and(|path| config_profile_has_key(&path, key)) - 8944
} - 8945
- 8946
fn config_profile_has_key(path: &std::path::Path, key: &str) -> bool { - 8947
let Some(value) = std::fs::read_to_string(path) - 8948
.ok() - 8949
.and_then(|raw| toml::from_str::<toml::Value>(&raw).ok()) - 8950
else { - 8951
return false; - 8952
}; - 8953
let Some(profile) = value.get("profile").and_then(toml::Value::as_str) else { - 8954
return false; - 8955
}; - 8956
value - 8957
.get("profiles") - 8958
.and_then(|profiles| profiles.get(profile)) - 8959
.and_then(|profile| profile.get(key)) - 8960
.is_some() - 8961
} - 8962
- 8963
fn self_path() -> std::path::PathBuf { - 8964
std::path::PathBuf::from(".vak/config.toml") - 8965
} - 8966
- 8967
/// Resolve `${NAME}` references in an MCP server env value through - 8968
/// `vak_config::get_var` (override → process env → dotenv). Returns None - 8969
/// when any reference is unresolved so callers can drop the pair instead of - 8970
/// leaking a literal placeholder into a child environment. - 8971
pub fn interpolate_env_var(value: &str) -> Option<String> { - 8972
interpolate_env_var_with(value, vak_config::get_var) - 8973
} - 8974
- 8975
fn interpolate_env_var_with( - 8976
value: &str, - 8977
mut lookup: impl FnMut(&str) -> Option<String>, - 8978
) -> Option<String> { - 8979
if !value.contains("${") { - 8980
return Some(value.to_string()); - 8981
} - 8982
let mut out = String::with_capacity(value.len()); - 8983
let mut rest = value; - 8984
while let Some(start) = rest.find("${") { - 8985
out.push_str(&rest[..start]); - 8986
let after = &rest[start + 2..]; - 8987
let end = after.find('}')?; - 8988
let name = &after[..end]; - 8989
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - 8990
return None; - 8991
} - 8992
out.push_str(&lookup(name)?); - 8993
rest = &after[end + 1..]; - 8994
} - 8995
out.push_str(rest); - 8996
Some(out) - 8997
} - 8998
- 8999
/// `CoreInner::mcp_cache`'s payload: the pool currently live for - 9000
/// `fingerprint`'s server set. - 9001
struct McpCache { - 9002
fingerprint: u64, - 9003
manager: Arc<vak_mcp::McpManager>, - 9004
} - 9005
- 9006
/// Identifies one resolved MCP server set for cache-invalidation purposes: - 9007
/// two calls that resolve to the same names/commands/args/env/network - 9008
/// settings get the same fingerprint and reuse one manager; anything that - 9009
/// changes what `effective_mcp()` returns (a runtime `set_mcp_servers`, a - 9010
/// plugin enabled/disabled) changes the fingerprint and rebuilds. Not a - 9011
/// security boundary — only used to decide "same manager or not". - 9012
fn mcp_fingerprint(servers: &[(String, vak_mcp::ServerConfig)]) -> u64 { - 9013
use std::hash::{Hash, Hasher}; - 9014
let mut sorted: Vec<&(String, vak_mcp::ServerConfig)> = servers.iter().collect(); - 9015
sorted.sort_by(|a, b| a.0.cmp(&b.0)); - 9016
let mut hasher = std::collections::hash_map::DefaultHasher::new(); - 9017
for (name, cfg) in sorted { - 9018
name.hash(&mut hasher); - 9019
cfg.command.hash(&mut hasher); - 9020
cfg.args.hash(&mut hasher); - 9021
cfg.network.hash(&mut hasher); - 9022
for (k, v) in &cfg.env { - 9023
k.hash(&mut hasher); - 9024
v.hash(&mut hasher); - 9025
} - 9026
} - 9027
hasher.finish() - 9028
} - 9029
- 9030
/// Session facts a tool needs at construction. The default is the - 9031
/// declaration view: no session yet, so nothing session-specific is bound. - 9032
#[derive(Debug, Clone, Default)] - 9033
struct ToolScope { - 9034
session_id: String, - 9035
agent_id: Option<String>, - 9036
audience_id: Option<String>, - 9037
} - 9038
- 9039
/// The MCP section of the prompt, rendered from the admitted capability - 9040
/// packet alone — one source, so the prompt can never describe servers the - 9041
/// turn did not admit. - 9042
/// - 9043
/// Each server is named with the tool *names* the on-demand pool last - 9044
/// observed (none before its first use; `configuration.tools`) and, when its - 9045
/// last attempt failed, that reason. No descriptions or schemas: `mcp` `list` - 9046
/// with a server returns those right before the call that needs them - 9047
/// (docs/design/68-context-engine.md §5). - 9048
fn mcp_config_section(servers: &[&CapabilityDescriptor]) -> String { - 9049
if servers.is_empty() { - 9050
return String::new(); - 9051
} - 9052
let mut section = String::from( - 9053
"\nMCP servers (call through the `mcp` tool: action \"list\" with a `server` returns its tools' schemas; action \"call\" with `server`, `tool`, and `arguments` runs one; never invent a tool name or argument):\n", - 9054
); - 9055
for capability in servers { - 9056
let tools: Vec<&str> = capability - 9057
.configuration - 9058
.get("tools") - 9059
.and_then(|t| t.as_array()) - 9060
.map(|tools| { - 9061
tools - 9062
.iter() - 9063
.filter_map(|tool| tool.get("name").and_then(|n| n.as_str())) - 9064
.collect() - 9065
}) - 9066
.unwrap_or_default(); - 9067
if tools.is_empty() { - 9068
section.push_str(&format!("- {}\n", capability.name)); - 9069
} else { - 9070
section.push_str(&format!("- {}: {}\n", capability.name, tools.join(", "))); - 9071
} - 9072
// Still callable — the pool retries on the next demand after its - 9073
// backoff — so the model is told, not denied. - 9074
if let Some(failure) = capability - 9075
.configuration - 9076
.get("last_failure") - 9077
.and_then(|f| f.as_str()) - 9078
{ - 9079
section.push_str(&format!( - 9080
" last attempt failed: {failure}. If the request needs it, try once more and otherwise tell the user it is unavailable and how to fix it: {}.\n", - 9081
capability::report::mcp_remedy(&capability.name) - 9082
)); - 9083
} - 9084
} - 9085
section - 9086
} - 9087
- 9088
/// Phase H MEA provider: diff the run-start checkpoint against disk. - 9089
struct CheckpointDelta { - 9090
home: PathBuf, - 9091
sid: String, - 9092
seq: u32, - 9093
cwd: PathBuf, - 9094
} - 9095
- 9096
impl vak_agent::WorkspaceDelta for CheckpointDelta { - 9097
fn summary(&self) -> Result<String, String> { - 9098
checkpoints::delta_summary( - 9099
&self.cwd.clone(), - 9100
&self.home.clone(), - 9101
self.sid.as_str(), - 9102
self.seq, - 9103
8192, - 9104
) - 9105
.map_err(|e| e.to_string()) - 9106
} - 9107
} - 9108
- 9109
#[cfg(test)] - 9110
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 9111
mod mcp_section_tests { - 9112
use super::mcp_config_section; - 9113
use vak_session::types::{CapabilityDescriptor, CapabilityInvocation, CapabilityKind}; - 9114
- 9115
fn server(name: &str, configuration: serde_json::Value) -> CapabilityDescriptor { - 9116
CapabilityDescriptor { - 9117
name: name.into(), - 9118
kind: CapabilityKind::McpServer, - 9119
invocation: CapabilityInvocation::ModelTool, - 9120
description: String::new(), - 9121
source: None, - 9122
digest: None, - 9123
provenance: None, - 9124
configuration, - 9125
} - 9126
} - 9127
- 9128
#[test] - 9129
fn configured_servers_remain_visible_without_a_catalog() { - 9130
let caps = [server("tavily", serde_json::Value::Null)]; - 9131
let refs: Vec<_> = caps.iter().collect(); - 9132
let section = mcp_config_section(&refs); - 9133
assert!(section.contains("tavily")); - 9134
assert!(section.contains("`mcp`")); - 9135
assert!(section.contains("action \"list\"")); - 9136
assert!( - 9137
!section.contains("Discovered MCP catalog"), - 9138
"an empty catalog must not be announced as one" - 9139
); - 9140
} - 9141
- 9142
/// The catalog is read from the admitted packet, not a second cache. - 9143
/// - 9144
/// This is the defect that reached a user: the packet admitted `tavily` - 9145
/// and the prompt named it, but the catalog lived in `Core::mcp_cache` - 9146
/// and had not landed when the session froze. The model saw a server - 9147
/// name with no tools behind it and answered "I do not have a tool that - 9148
/// can provide real-time weather information" — with a connected, - 9149
/// admitted search server attached. One source means the two can no - 9150
/// longer disagree. - 9151
#[test] - 9152
fn the_catalog_comes_from_the_packet_with_exact_names() { - 9153
let caps = [server( - 9154
"tavily", - 9155
serde_json::json!({ - 9156
"tools": [{ - 9157
"name": "tavily_search", - 9158
"description": "Search the web", - 9159
"inputSchema": { - 9160
"type": "object", - 9161
"properties": {"query": {"type": "string"}}, - 9162
"required": ["query"] - 9163
} - 9164
}] - 9165
}), - 9166
)]; - 9167
let refs: Vec<_> = caps.iter().collect(); - 9168
let section = mcp_config_section(&refs); - 9169
assert!(section.contains("\n- tavily: tavily_search\n")); - 9170
assert!( - 9171
!section.contains("Search the web"), - 9172
"descriptions come with the schema from `mcp list`, not in every prompt" - 9173
); - 9174
} - 9175
- 9176
/// Schemas stay out of the prompt (docs/design/68 §5): a model reaches - 9177
/// one input schema at a time via `mcp list`, never a full inline dump. - 9178
#[test] - 9179
fn the_catalog_never_inlines_input_schemas() { - 9180
let caps = [server( - 9181
"tavily", - 9182
serde_json::json!({ - 9183
"tools": [{ - 9184
"name": "tavily_search", - 9185
"description": "Search the web", - 9186
"inputSchema": { - 9187
"type": "object", - 9188
"properties": {"query": {"type": "string"}}, - 9189
"required": ["query"] - 9190
} - 9191
}] - 9192
}), - 9193
)]; - 9194
let refs: Vec<_> = caps.iter().collect(); - 9195
let section = mcp_config_section(&refs); - 9196
assert!(!section.contains("inputSchema")); - 9197
assert!(!section.contains('{')); - 9198
} - 9199
} - 9200
- 9201
#[cfg(test)] - 9202
mod plugin_runtime_tests { - 9203
#![allow(clippy::unwrap_used)] - 9204
- 9205
use super::*; - 9206
- 9207
#[test] - 9208
fn enabled_plugin_mcp_is_namespaced_and_disabled_plugin_is_invisible() { - 9209
let dir = tempfile::tempdir().unwrap(); - 9210
let package = dir.path().join("plugin"); - 9211
std::fs::create_dir_all(&package).unwrap(); - 9212
std::fs::write( - 9213
package.join("vak-plugin.json"), - 9214
r#"{"schema":1,"name":"tools-pack","version":"1.0.0","description":"Tools","license":"MIT","components":{"mcp":["mcp.json"]}}"#, - 9215
) - 9216
.unwrap(); - 9217
std::fs::write( - 9218
package.join("mcp.json"), - 9219
r#"{"mcpServers":{"lookup":{"command":"lookup-bin","args":["--safe"]}}}"#, - 9220
) - 9221
.unwrap(); - 9222
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 9223
core.set_sessions_home(dir.path().join("home")); - 9224
let store = vak_plugin::PluginStore::new(dir.path().join(".vak")); - 9225
store - 9226
.install_local(package.as_path(), vak_plugin::InstallOptions::default()) - 9227
.unwrap(); - 9228
assert!( - 9229
!core - 9230
.effective_mcp() - 9231
.servers - 9232
.contains_key("plugin.tools-pack.lookup") - 9233
); - 9234
store.enable("tools-pack").unwrap(); - 9235
let servers = core.effective_mcp().servers; - 9236
let server = servers.get("plugin.tools-pack.lookup").unwrap(); - 9237
assert_eq!(server.command, "lookup-bin"); - 9238
assert_eq!(server.args, ["--safe"]); - 9239
assert!(!server.network); - 9240
} - 9241
- 9242
/// The whole point of caching `McpManager` on `Core` (see - 9243
/// `mcp_manager()`): a hot plugin enable/disable — the same kind of - 9244
/// on-the-fly change `set_mcp_servers` makes at runtime — must be - 9245
/// picked up on the very next call, not require a restart, while an - 9246
/// unrelated repeat call in between reuses the same manager instance - 9247
/// rather than respawning server connections for no reason. - 9248
#[tokio::test] - 9249
async fn mcp_manager_reuses_instance_and_picks_up_hot_plugin_toggle() { - 9250
let dir = tempfile::tempdir().unwrap(); - 9251
let package = dir.path().join("plugin"); - 9252
std::fs::create_dir_all(&package).unwrap(); - 9253
std::fs::write( - 9254
package.join("vak-plugin.json"), - 9255
r#"{"schema":1,"name":"tools-pack","version":"1.0.0","description":"Tools","license":"MIT","components":{"mcp":["mcp.json"]}}"#, - 9256
) - 9257
.unwrap(); - 9258
std::fs::write( - 9259
package.join("mcp.json"), - 9260
r#"{"mcpServers":{"lookup":{"command":"lookup-bin","args":["--safe"]}}}"#, - 9261
) - 9262
.unwrap(); - 9263
isolate_global_config(); - 9264
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 9265
core.set_sessions_home(dir.path().join("home")); - 9266
let store = vak_plugin::PluginStore::new(dir.path().join(".vak")); - 9267
store - 9268
.install_local(package.as_path(), vak_plugin::InstallOptions::default()) - 9269
.unwrap(); - 9270
- 9271
// Nothing configured yet: no manager. - 9272
assert!(core.mcp_manager().is_none()); - 9273
- 9274
// Enable on the fly: next call sees the new server immediately. - 9275
store.enable("tools-pack").unwrap(); - 9276
let first = core.mcp_manager().unwrap(); - 9277
assert_eq!(first.server_names(), vec!["plugin.tools-pack.lookup"]); - 9278
- 9279
// The contributed server's egress follows the SAME effective-policy - 9280
// seam the runner tools read: deny-by-default, and a hot persisted - 9281
// grant reaches it next time `effective_mcp` resolves - not after a - 9282
// restart. (Regression for a base-config read that left the server - 9283
// stale for the whole process once an override landed.) - 9284
assert!( - 9285
!core.effective_mcp().servers["plugin.tools-pack.lookup"].network, - 9286
"plugin-contributed MCP server must deny egress by default" - 9287
); - 9288
core.apply_persisted_plugins(vak_config::PluginResolved { - 9289
network_allow: Some(vec!["tools-pack".into()]), - 9290
..core.effective_plugins() - 9291
}); - 9292
assert!( - 9293
core.effective_mcp().servers["plugin.tools-pack.lookup"].network, - 9294
"persisted grant must reach the contributed MCP server live" - 9295
); - 9296
core.apply_persisted_plugins(vak_config::PluginResolved { - 9297
network_allow: None, - 9298
..core.effective_plugins() - 9299
}); - 9300
assert!( - 9301
!core.effective_mcp().servers["plugin.tools-pack.lookup"].network, - 9302
"persisted revoke must take the contributed server's egress away" - 9303
); - 9304
- 9305
// Same server set: same manager instance (no respawn/reconnect). - 9306
let second = core.mcp_manager().unwrap(); - 9307
assert!( - 9308
Arc::ptr_eq(&first, &second), - 9309
"unchanged server set must reuse the cached manager" - 9310
); - 9311
- 9312
// Disable on the fly: next call sees it's gone immediately, no - 9313
// restart required. - 9314
store.disable("tools-pack").unwrap(); - 9315
assert!(core.mcp_manager().is_none()); - 9316
} - 9317
} - 9318
- 9319
#[cfg(test)] - 9320
mod webfetch_classification_tests { - 9321
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 9322
use super::*; - 9323
- 9324
use vak_config::PermissionMode; - 9325
use vak_permission::{Decision, Mode, PermissionEngine}; - 9326
- 9327
fn decide( - 9328
cfg: &vak_config::Config, - 9329
extra: &[String], - 9330
mode: PermissionMode, - 9331
tool: &str, - 9332
) -> Decision { - 9333
let dir = tempfile::tempdir().unwrap(); - 9334
let engine: PermissionEngine = build_engine_with(cfg, extra).expect("engine builds"); - 9335
engine.evaluate(tool, &serde_json::json!({}), to_mode(mode), dir.path()) - 9336
} - 9337
- 9338
fn to_mode(mode: PermissionMode) -> Mode { - 9339
match mode { - 9340
PermissionMode::ReadOnly => Mode::ReadOnly, - 9341
PermissionMode::WorkspaceWrite => Mode::WorkspaceWrite, - 9342
PermissionMode::FullAccess => Mode::FullAccess, - 9343
} - 9344
} - 9345
- 9346
#[test] - 9347
fn no_rule_outside_fullaccess_asks_fullaccess_allows() { - 9348
let cfg = vak_config::Config::default(); - 9349
for mode in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 9350
for tool in NETWORK_TOOLS { - 9351
let d = decide(&cfg, &[], mode, tool); - 9352
assert!( - 9353
matches!(d, Decision::Ask { .. }), - 9354
"{tool} in {mode:?} must Ask, got {d:?}" - 9355
); - 9356
} - 9357
} - 9358
for tool in NETWORK_TOOLS { - 9359
let d = decide(&cfg, &[], PermissionMode::FullAccess, tool); - 9360
assert!( - 9361
matches!(d, Decision::Allow), - 9362
"{tool} under FullAccess must Allow, got {d:?}" - 9363
); - 9364
} - 9365
} - 9366
- 9367
#[test] - 9368
fn explicit_allow_rule_wins_in_every_restricted_mode() { - 9369
for spec in ["webfetch", "+webfetch"] { - 9370
let mut cfg = vak_config::Config::default(); - 9371
cfg.allow.push(spec.into()); - 9372
for mode in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 9373
let d = decide(&cfg, &[], mode, "webfetch"); - 9374
assert!(matches!(d, Decision::Allow), "{spec:?} in {mode:?}: {d:?}"); - 9375
} - 9376
} - 9377
// Learned rules ride in via `extra`. - 9378
let cfg = vak_config::Config::default(); - 9379
for mode in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 9380
let d = decide(&cfg, &["+webfetch".to_string()], mode, "webfetch"); - 9381
assert!(matches!(d, Decision::Allow), "learned in {mode:?}: {d:?}"); - 9382
} - 9383
} - 9384
- 9385
#[test] - 9386
fn explicit_deny_rule_wins_everywhere() { - 9387
let mut cfg = vak_config::Config::default(); - 9388
cfg.deny.push("-webfetch".into()); - 9389
for mode in [ - 9390
PermissionMode::ReadOnly, - 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"] {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.