- 8301
#[test] - 8302
fn surface_reaches_the_assembled_system_prompt() { - 8303
let dir = tempfile::tempdir().unwrap(); - 8304
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 8305
- 8306
let cli = core - 8307
.clone() - 8308
.with_surface(crate::Surface::Cli) - 8309
.system_prompt(); - 8310
let chat = core - 8311
.clone() - 8312
.with_surface(crate::Surface::Chat { - 8313
channel: "telegram".into(), - 8314
}) - 8315
.system_prompt(); - 8316
- 8317
assert!(cli.contains("Surface: terminal CLI"), "{cli}"); - 8318
assert!(chat.contains("Surface: chat gateway (telegram)"), "{chat}"); - 8319
assert_ne!(cli, chat, "every surface was handed the same prompt"); - 8320
- 8321
// Unset stays honest rather than guessing a surface. - 8322
assert!(core.system_prompt().contains("Surface: unknown")); - 8323
} - 8324
} - 8325
- 8326
#[cfg(test)] - 8327
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 8328
mod learned_rule_tests { - 8329
use super::*; - 8330
use serde_json::json; - 8331
- 8332
/// Every derived rule must cover the call it came from. A rule that does - 8333
/// not is granting something the operator never looked at, which is why - 8334
/// `learn_from_call` round-trips before writing. - 8335
fn derives_and_matches(tool: &str, args: serde_json::Value, expected: &str) { - 8336
let spec = scoped_allow_rule(tool, &args).expect("derives a rule"); - 8337
assert_eq!(spec, expected); - 8338
let rule = vak_permission::Rule::parse(&spec).expect("parses"); - 8339
assert!(rule.matches(tool, &args), "{spec} must match its own call"); - 8340
} - 8341
- 8342
#[test] - 8343
fn bash_narrows_to_the_command_name() { - 8344
derives_and_matches( - 8345
"bash", - 8346
json!({ "command": "git status --short" }), - 8347
"+bash(git *)", - 8348
); - 8349
} - 8350
- 8351
#[test] - 8352
fn file_tools_narrow_to_the_exact_path() { - 8353
derives_and_matches( - 8354
"write", - 8355
json!({ "path": "src/main.rs" }), - 8356
"+write(src/main.rs)", - 8357
); - 8358
derives_and_matches( - 8359
"edit", - 8360
json!({ "path": "src/main.rs" }), - 8361
"+edit(src/main.rs)", - 8362
); - 8363
} - 8364
- 8365
#[test] - 8366
fn mcp_narrows_to_the_server() { - 8367
derives_and_matches( - 8368
"mcp", - 8369
json!({ "action": "call", "server": "tavily", "tool": "search" }), - 8370
"+mcp(tavily/*)", - 8371
); - 8372
} - 8373
- 8374
#[test] - 8375
fn a_command_whose_effects_cannot_be_enumerated_is_never_remembered() { - 8376
// Each of these hides an effect from segmentation. Deriving - 8377
// `+bash(echo *)` from the first would grant the substitution too. - 8378
for command in [ - 8379
"echo $(rm -rf /)", - 8380
"echo `whoami`", - 8381
"cat secrets > /etc/passwd", - 8382
"git status; rm -rf /", - 8383
"git commit -m \"unbalanced", - 8384
] { - 8385
assert!( - 8386
scoped_allow_rule("bash", &json!({ "command": command })).is_none(), - 8387
"must refuse to narrow: {command}" - 8388
); - 8389
} - 8390
} - 8391
- 8392
/// The round-trip check does real work: a path containing glob - 8393
/// metacharacters produces a spec that parses fine and then matches - 8394
/// something OTHER than the file it came from. Writing it would grant a - 8395
/// pattern the operator never looked at. - 8396
#[test] - 8397
fn a_path_that_is_also_a_glob_is_refused_rather_than_mis_granted() { - 8398
let dir = tempfile::tempdir().unwrap(); - 8399
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 8400
let args = json!({ "path": "src/a[1].rs" }); - 8401
// The spec is derivable and syntactically valid... - 8402
assert_eq!( - 8403
scoped_allow_rule("write", &args).as_deref(), - 8404
Some("+write(src/a[1].rs)") - 8405
); - 8406
// ...but it does not cover its own call, so nothing is written. - 8407
assert!(core.learn_from_call("write", &args).is_err()); - 8408
assert!(!dir.path().join(PERMISSIONS_LOCAL_FILE).exists()); - 8409
} - 8410
- 8411
#[test] - 8412
fn network_tools_are_never_remembered_from_one_call() { - 8413
// A URL does not generalize, and a blanket `+webfetch` is a config - 8414
// decision rather than something that falls out of a single yes. - 8415
assert!(scoped_allow_rule("webfetch", &json!({ "url": "https://x" })).is_none()); - 8416
assert!(scoped_allow_rule("browse", &json!({ "url": "https://x" })).is_none()); - 8417
} - 8418
- 8419
#[test] - 8420
fn learning_persists_the_rule_and_the_engine_sees_it_immediately() { - 8421
let dir = tempfile::tempdir().unwrap(); - 8422
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 8423
let args = json!({ "command": "cargo test" }); - 8424
- 8425
// Before: workspace-write sends bash to an approval gate. - 8426
let engine = core - 8427
.build_permission_engine(&core.extra_allow_snapshot()) - 8428
.unwrap(); - 8429
assert!(matches!( - 8430
engine.evaluate( - 8431
"bash", - 8432
&args, - 8433
vak_permission::Mode::WorkspaceWrite, - 8434
core.cwd() - 8435
), - 8436
vak_permission::Decision::Ask { .. } - 8437
)); - 8438
- 8439
let spec = core.learn_from_call("bash", &args).expect("learns"); - 8440
assert_eq!(spec, "+bash(cargo *)"); - 8441
- 8442
// After: the same call is allowed, with no restart. - 8443
let engine = core - 8444
.build_permission_engine(&core.extra_allow_snapshot()) - 8445
.unwrap(); - 8446
assert!(matches!( - 8447
engine.evaluate( - 8448
"bash", - 8449
&args, - 8450
vak_permission::Mode::WorkspaceWrite, - 8451
core.cwd() - 8452
), - 8453
vak_permission::Decision::Allow - 8454
)); - 8455
assert!(dir.path().join(PERMISSIONS_LOCAL_FILE).is_file()); - 8456
} - 8457
- 8458
#[test] - 8459
fn a_learned_allow_never_shadows_an_explicit_deny() { - 8460
let dir = tempfile::tempdir().unwrap(); - 8461
std::fs::create_dir_all(dir.path().join(".vak")).unwrap(); - 8462
std::fs::write( - 8463
dir.path().join(".vak/config.toml"), - 8464
"deny = [\"Bash(cargo *)\"]\n", - 8465
) - 8466
.unwrap(); - 8467
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 8468
let args = json!({ "command": "cargo test" }); - 8469
core.learn_from_call("bash", &args).expect("learns"); - 8470
- 8471
let engine = core - 8472
.build_permission_engine(&core.extra_allow_snapshot()) - 8473
.unwrap(); - 8474
assert!( - 8475
matches!( - 8476
engine.evaluate( - 8477
"bash", - 8478
&args, - 8479
vak_permission::Mode::WorkspaceWrite, - 8480
core.cwd() - 8481
), - 8482
vak_permission::Decision::Deny { .. } - 8483
), - 8484
"severity aggregation must keep the deny on top" - 8485
); - 8486
} - 8487
- 8488
#[test] - 8489
fn an_untrusted_workspace_cannot_write_a_grant_file() { - 8490
let dir = tempfile::tempdir().unwrap(); - 8491
let core = Core::new_with_trust(dir.path().to_path_buf(), false).unwrap(); - 8492
assert!( - 8493
core.learn_from_call("bash", &json!({ "command": "git status" })) - 8494
.is_err() - 8495
); - 8496
assert!(!dir.path().join(PERMISSIONS_LOCAL_FILE).exists()); - 8497
} - 8498
- 8499
#[test] - 8500
fn runtime_rules_replace_the_loaded_ones_for_every_engine_build() { - 8501
let dir = tempfile::tempdir().unwrap(); - 8502
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 8503
let args = json!({ "command": "ls -la" }); - 8504
assert!(matches!( - 8505
core.build_permission_engine(&[]).unwrap().evaluate( - 8506
"bash", - 8507
&args, - 8508
vak_permission::Mode::WorkspaceWrite, - 8509
core.cwd() - 8510
), - 8511
vak_permission::Decision::Ask { .. } - 8512
)); - 8513
- 8514
core.apply_persisted_permission_rules(vec!["Bash(ls *)".into()], Vec::new(), Vec::new()); - 8515
assert!( - 8516
matches!( - 8517
core.build_permission_engine(&[]).unwrap().evaluate( - 8518
"bash", - 8519
&args, - 8520
vak_permission::Mode::WorkspaceWrite, - 8521
core.cwd() - 8522
), - 8523
vak_permission::Decision::Allow - 8524
), - 8525
"an engine built after the override must see it" - 8526
); - 8527
} - 8528
} - 8529
- 8530
pub fn build_engine( - 8531
config: &vak_config::Config, - 8532
) -> Result<vak_permission::PermissionEngine, CoreError> { - 8533
build_engine_with(config, &[]) - 8534
} - 8535
- 8536
/// `extra` carries learned rules from permissions.local.toml; the engine - 8537
/// aggregates by severity, so they can never shadow explicit denies. - 8538
/// The one permission-engine constructor. - 8539
/// - 8540
/// There used to be a second, `build_engine_for_mode`, which existed only - 8541
/// to inject synthetic `?webfetch` / `?browse` rules outside FullAccess. - 8542
/// That injection is gone — `PermissionEngine`'s own mode arms classify - 8543
/// network tools now — and with it the reason for a second constructor. - 8544
/// Two ways to build the object that decides access is precisely how the - 8545
/// layers drifted apart in the first place: the mode-aware one silently - 8546
/// disagreed with this one about whether `auto-approve` applied. - 8547
pub fn build_engine_with( - 8548
config: &vak_config::Config, - 8549
extra: &[String], - 8550
) -> Result<vak_permission::PermissionEngine, CoreError> { - 8551
vak_permission::PermissionEngine::from_rule_strings(&rule_specs(config, extra)) - 8552
.map(|engine| engine.with_presenting_tools(presentation_tools::presenting_tool_names())) - 8553
.map_err(CoreError::Rule) - 8554
} - 8555
- 8556
fn rule_specs(config: &vak_config::Config, extra: &[String]) -> Vec<String> { - 8557
rule_specs_from(&config.allow, &config.ask, &config.deny, extra) - 8558
} - 8559
- 8560
/// Flatten three rule lists plus learned extras into engine specs. - 8561
/// - 8562
/// Split out from [`rule_specs`] so a `Core` holding a runtime override can - 8563
/// reach the same flattening without synthesizing a whole `Config`. Deny is - 8564
/// emitted first purely for readability in a dump — the engine aggregates by - 8565
/// severity and does not depend on order. - 8566
fn rule_specs_from( - 8567
allow: &[String], - 8568
ask: &[String], - 8569
deny: &[String], - 8570
extra: &[String], - 8571
) -> Vec<String> { - 8572
let mut specs: Vec<String> = Vec::new(); - 8573
for (list, prefix) in [(deny, "-"), (ask, "?"), (allow, "+")] { - 8574
for s in list { - 8575
let spec = if s.starts_with(['+', '-', '?']) { - 8576
s.clone() - 8577
} else { - 8578
format!("{prefix}{s}") - 8579
}; - 8580
specs.push(spec); - 8581
} - 8582
} - 8583
specs.extend(extra.iter().cloned()); - 8584
specs - 8585
} - 8586
- 8587
/// The narrowest allow rule that still covers one approved call, or `None` - 8588
/// when the call cannot be narrowed safely. - 8589
/// - 8590
/// `None` is the important half. A bash command whose structure hides its - 8591
/// effects — command substitution, a redirection to a real path, an - 8592
/// unbalanced quote — has no first word that means anything, and writing - 8593
/// `bash(<something> *)` for it would grant a shape the operator never - 8594
/// inspected. Those stay session-only: approve them again next time. - 8595
/// - 8596
/// The shapes match `vak_permission`'s own argument candidates, which is - 8597
/// what makes the round-trip check in [`Core::learn_from_call`] meaningful - 8598
/// rather than a tautology over a string this function invented. - 8599
pub fn scoped_allow_rule(tool: &str, args: &serde_json::Value) -> Option<String> { - 8600
match tool { - 8601
"bash" => { - 8602
let command = args.get("command")?.as_str()?; - 8603
// Exactly one executable segment, enumerable, no hidden effects. - 8604
let units = vak_permission::rules::allow_coverage_units(tool, args)?; - 8605
if units.len() != 1 { - 8606
return None; - 8607
} - 8608
let first = command.split_whitespace().next()?; - 8609
if first.is_empty() || first.contains(['/', '$', '`', '"', '\'']) { - 8610
return None; - 8611
} - 8612
Some(format!("+bash({first} *)")) - 8613
} - 8614
// The exact path, not its directory: an approval for one file is not - 8615
// an approval for its neighbours. - 8616
"write" | "edit" => Some(format!("+{tool}({})", args.get("path")?.as_str()?)), - 8617
"mcp" => { - 8618
if args.get("action")?.as_str()? != "call" { - 8619
return None; - 8620
} - 8621
Some(format!("+mcp({}/*)", args.get("server")?.as_str()?)) - 8622
} - 8623
"task" => Some(format!("+task({})", args.get("label")?.as_str()?)), - 8624
// Everything else — network tools included — is deliberately absent. - 8625
// `webfetch` takes a URL that no glob over one call generalizes - 8626
// safely, and a blanket `+webfetch` is a decision for the config - 8627
// file, made deliberately, not one to fall out of a single yes. - 8628
_ => None, - 8629
} - 8630
} - 8631
- 8632
/// Tools whose reach exceeds the workspace: network-capable capabilities - 8633
/// registered next to built-ins (docs/design/29-personal-os.md P4). - 8634
pub const NETWORK_TOOLS: [&str; 2] = ["webfetch", "browse"]; - 8635
- 8636
/// What a channel overlay's `tools_allow = []` ("block this category") - 8637
/// denies at the rule layer. Visibility filtering already removes these - 8638
/// from the registry; the rules are the second, execution-scoped half, so - 8639
/// a path that assembles its own tool list cannot reintroduce one. - 8640
const CHANNEL_BLOCKABLE_TOOLS: [&str; 10] = [ - 8641
"read", - 8642
"write", - 8643
"edit", - 8644
"bash", - 8645
"glob", - 8646
"grep", - 8647
"remember", - 8648
"propose_skill", - 8649
"webfetch", - 8650
"browse", - 8651
]; - 8652
- 8653
trait KebabLower { - 8654
fn to_kebab_lowercase(&self) -> String; - 8655
} - 8656
- 8657
impl KebabLower for str { - 8658
fn to_kebab_lowercase(&self) -> String { - 8659
self.chars() - 8660
.map(|c| { - 8661
if c.is_uppercase() { - 8662
c.to_ascii_lowercase() - 8663
} else { - 8664
c - 8665
} - 8666
}) - 8667
.map(|c| if c == '_' { '-' } else { c }) - 8668
.collect() - 8669
} - 8670
} - 8671
- 8672
/// Session ids are UUIDv7, matching entry ids in the same tree: time- - 8673
/// ordered and collision-safe even across clock rewinds (the previous - 8674
/// nanosecond-hex scheme appended a second header onto an existing file - 8675
/// on collision). - 8676
fn uuid_like() -> String { - 8677
uuid::Uuid::now_v7().to_string() - 8678
} - 8679
- 8680
pub fn build_hooks(config: &vak_config::Config) -> Result<Vec<vak_hooks::HookDef>, CoreError> { - 8681
build_hooks_from(&config.hooks) - 8682
} - 8683
- 8684
fn normalize_capability_message( - 8685
mut message: vak_llm::Message, - 8686
capabilities: &[CapabilityDescriptor], - 8687
) -> Result<vak_llm::Message, String> { - 8688
let Some(vak_llm::ContentBlock::Text { text }) = message - 8689
.content - 8690
.iter_mut() - 8691
.find(|block| matches!(block, vak_llm::ContentBlock::Text { .. })) - 8692
else { - 8693
return Ok(message); - 8694
}; - 8695
let frozen_skills = skills::frozen_from_capabilities(capabilities); - 8696
let expanded = skills::expand_invocation(text, &frozen_skills)? - 8697
.or_else(|| custom_commands::expand_capability_invocation(capabilities, text)); - 8698
if let Some(expanded) = expanded { - 8699
*text = expanded; - 8700
} - 8701
Ok(message) - 8702
} - 8703
- 8704
fn build_hooks_from( - 8705
config_hooks: &[vak_config::HookConfig], - 8706
) -> Result<Vec<vak_hooks::HookDef>, CoreError> { - 8707
config_hooks - 8708
.iter() - 8709
.filter(|hook| hook.enabled) - 8710
.map(|hook| { - 8711
hook_def(hook).map_err(|reason| { - 8712
CoreError::Config(vak_config::ConfigError::Read { - 8713
path: self_path(), - 8714
source: std::io::Error::other(reason), - 8715
}) - 8716
}) - 8717
}) - 8718
.collect() - 8719
} - 8720
- 8721
/// The one reading of a configured hook, shared by config validation and the - 8722
/// per-turn capability pipeline so the two can never disagree about what a - 8723
/// hook means. - 8724
pub fn hook_def(hook: &vak_config::HookConfig) -> Result<vak_hooks::HookDef, String> { - 8725
let event = match hook.event.as_str() { - 8726
"session-start" | "session_start" | "start" => vak_hooks::HookEvent::SessionStart, - 8727
"pre-tool-use" | "pre_tool_use" => vak_hooks::HookEvent::PreToolUse, - 8728
"post-tool-use" | "post_tool_use" => vak_hooks::HookEvent::PostToolUse, - 8729
"stop" => vak_hooks::HookEvent::Stop, - 8730
other => return Err(format!("unknown hook event '{other}'")), - 8731
}; - 8732
let matcher = match &hook.matcher { - 8733
Some(m) if !m.trim().is_empty() => { - 8734
Some(vak_permission::Rule::parse(m).map_err(|error| format!("hook matcher: {error}"))?) - 8735
} - 8736
_ => None, - 8737
}; - 8738
let failure_mode = match hook.failure_mode.as_deref().unwrap_or("open") { - 8739
"open" => vak_hooks::HookFailureMode::Open, - 8740
"closed" => vak_hooks::HookFailureMode::Closed, - 8741
other => return Err(format!("unknown hook failure mode '{other}'")), - 8742
}; - 8743
Ok(vak_hooks::HookDef { - 8744
event, - 8745
matcher, - 8746
command: hook.command.clone(), - 8747
timeout_ms: hook.timeout_ms.unwrap_or(vak_hooks::DEFAULT_TIMEOUT_MS), - 8748
failure_mode, - 8749
refusal: None, - 8750
}) - 8751
} - 8752
- 8753
impl Core { - 8754
/// Background reflection seam (docs/design/29 P1): after a completed - 8755
/// turn on ANY surface, offer one auxiliary-model reflection pass. - 8756
/// - 8757
/// Contract: background reflection is best-effort by design. It never - 8758
/// blocks or fails a completed turn — every unhappy path collapses into - 8759
/// [`reflection::ReflectionOutcome::Skipped`] with a static reason, - 8760
/// never `Err`. Dispatch is budget-admitted BEFORE any provider call - 8761
/// through the same gate path runs use, so reflection can never bypass - 8762
/// a day/run cap; concurrent turns over the same session tail collapse - 8763
/// to a single pass via an in-flight marker; and all reflection logic - 8764
/// is the shared implementation in [`crate::reflection`] (no fork). - 8765
pub async fn reflect_after_turn( - 8766
&self, - 8767
session: &SessionLog, - 8768
final_text: &str, - 8769
) -> reflection::ReflectionOutcome { - 8770
if !self.effective_memory_reflection() { - 8771
return reflection::ReflectionOutcome::Skipped { - 8772
reason: "reflection-disabled", - 8773
}; - 8774
} - 8775
if !self.effective_memory_write_enabled() { - 8776
return reflection::ReflectionOutcome::Skipped { - 8777
reason: "memory-writes-disabled", - 8778
}; - 8779
} - 8780
if matches!( - 8781
self.effective_permission_mode(), - 8782
vak_config::PermissionMode::ReadOnly - 8783
) { - 8784
return reflection::ReflectionOutcome::Skipped { - 8785
reason: "permission-mode-read-only", - 8786
}; - 8787
} - 8788
if !self.memory_write_allowed() { - 8789
return reflection::ReflectionOutcome::Skipped { - 8790
reason: "memory-write-not-authorized", - 8791
}; - 8792
} - 8793
let sid = session - 8794
.header() - 8795
.map(|h| h.session_id.clone()) - 8796
.unwrap_or_default(); - 8797
let Ok(provider) = self.provider() else { - 8798
return reflection::ReflectionOutcome::Skipped { - 8799
reason: "provider-unready", - 8800
}; - 8801
}; - 8802
// One pass per session tail at a time, across every surface in - 8803
// this process. - 8804
let Some(_in_flight) = reflection::InFlightGuard::acquire(&sid) else { - 8805
return reflection::ReflectionOutcome::Skipped { - 8806
reason: "already-in-flight", - 8807
}; - 8808
}; - 8809
- 8810
// Reflection distils what the user and model said. A runtime nudge - 8811
// is neither, and rendered as `user: …` it would be memorised as a - 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!(
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.