- 2001
continue; - 2002
}; - 2003
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else { - 2004
continue; - 2005
}; - 2006
let Some(servers) = value - 2007
.get("mcpServers") - 2008
.and_then(serde_json::Value::as_object) - 2009
else { - 2010
continue; - 2011
}; - 2012
for (name, raw) in servers { - 2013
let Some(command) = raw.get("command").and_then(serde_json::Value::as_str) - 2014
else { - 2015
continue; - 2016
}; - 2017
let args = raw - 2018
.get("args") - 2019
.and_then(serde_json::Value::as_array) - 2020
.map(|values| { - 2021
values - 2022
.iter() - 2023
.filter_map(serde_json::Value::as_str) - 2024
.map(str::to_string) - 2025
.collect() - 2026
}) - 2027
.unwrap_or_default(); - 2028
let env = raw - 2029
.get("env") - 2030
.and_then(serde_json::Value::as_object) - 2031
.map(|values| { - 2032
values - 2033
.iter() - 2034
.filter_map(|(key, value)| { - 2035
value.as_str().map(|v| (key.clone(), v.to_string())) - 2036
}) - 2037
.collect() - 2038
}) - 2039
.unwrap_or_default(); - 2040
let key = format!("plugin.{}.{}", plugin.name, name); - 2041
// Same effective-policy seam the runtime tools read: - 2042
// the persisted `network_allow`/`network_deny` override - 2043
// refreshes `effective_plugins()` live, so a Settings - 2044
// toggle reaches a plugin-contributed server exactly - 2045
// like it reaches its runner tool. Reading the base - 2046
// config instead would keep this server stale for the - 2047
// whole process after an override lands. - 2048
let net_allowed = self.effective_plugins().is_network_allowed(&plugin.name) - 2049
&& self - 2050
.channel_policy() - 2051
.as_ref() - 2052
.map(|p| !p.plugins_network_deny.contains(&plugin.name)) - 2053
.unwrap_or(true); - 2054
config - 2055
.servers - 2056
.entry(key) - 2057
.or_insert(vak_config::McpServerConfig { - 2058
command: command.to_string(), - 2059
args, - 2060
env, - 2061
network: net_allowed, - 2062
// Plugin-contributed servers declare nothing - 2063
// by default, which keeps them reachable: - 2064
// undeclared is never sliced away. - 2065
serves: Vec::new(), - 2066
}); - 2067
} - 2068
} - 2069
} - 2070
} - 2071
} - 2072
- 2073
fn plugin_mcp_invocation_context(&self) -> Vec<(vak_plugin::PluginStore, String, String)> { - 2074
let mut context = Vec::new(); - 2075
for root in self.capability_roots() { - 2076
let store = vak_plugin::PluginStore::new(root.path); - 2077
let Ok(plugins) = store.enabled() else { - 2078
continue; - 2079
}; - 2080
context.extend( - 2081
plugins - 2082
.into_iter() - 2083
.map(|plugin| (store.clone(), plugin.name, plugin.trace_id)), - 2084
); - 2085
} - 2086
context - 2087
} - 2088
- 2089
pub fn apply_channel_policy(&self, policy: vak_config::ChannelPolicy) { - 2090
if let Ok(mut current) = self.inner.channel_policy.lock() { - 2091
*current = Some(policy); - 2092
} - 2093
} - 2094
- 2095
pub fn channel_policy(&self) -> Option<vak_config::ChannelPolicy> { - 2096
self.inner - 2097
.channel_policy - 2098
.lock() - 2099
.ok() - 2100
.and_then(|p| p.clone()) - 2101
} - 2102
- 2103
/// Whether a channel overlay permits a named capability. Inheritance is - 2104
/// represented by no policy and therefore permits the capability here; - 2105
/// the ordinary permission engine still decides whether execution is - 2106
/// allowed for the current mode. - 2107
pub fn channel_tool_allowed(&self, tool: &str) -> bool { - 2108
self.channel_policy() - 2109
.is_none_or(|policy| Self::allowed_by(&policy.tools_allow, &policy.tools_deny, tool)) - 2110
} - 2111
- 2112
/// Compile this turn's channel overlay into permission rules. - 2113
/// - 2114
/// **Restrictive only** (AGENTS.md invariant 20), and that is the whole - 2115
/// point of it living in one place. An overlay's `_allow` list is a - 2116
/// *visibility* narrowing — "this chat may reach these and nothing - 2117
/// else" — already enforced by dropping everything unlisted from the - 2118
/// tool registry (`channel_tool_allowed`) and, for MCP, by `McpTool`'s - 2119
/// own `server/tool` glob at call time. - 2120
/// - 2121
/// It must never become `+` allow rules. Both call sites used to do - 2122
/// exactly that, and a blanket `+bash` / `+mcp` outranks the mode - 2123
/// default that would otherwise have raised an approval gate — so - 2124
/// `tools_allow = ["bash"]`, written to *narrow* a chat to one tool, - 2125
/// silently handed that chat unattended shell execution, and - 2126
/// `mcp_allow = ["tavily/tavily_search"]` removed the approval gate - 2127
/// from every MCP call the glob still admitted. Adding a restriction - 2128
/// must never remove one. - 2129
fn channel_permission_rules(&self) -> Vec<String> { - 2130
let mut rules = self.extra_allow_snapshot(); - 2131
let Some(policy) = self.channel_policy() else { - 2132
return rules; - 2133
}; - 2134
// `Some([])` is "block this category outright"; `Some([..])` is a - 2135
// narrowing enforced by visibility, and contributes no rule here. - 2136
if policy.tools_allow.as_ref().is_some_and(|a| a.is_empty()) { - 2137
rules.extend( - 2138
CHANNEL_BLOCKABLE_TOOLS - 2139
.iter() - 2140
.map(|tool| format!("-{tool}")), - 2141
); - 2142
} - 2143
rules.extend( - 2144
policy - 2145
.tools_deny - 2146
.iter() - 2147
.map(|pattern| format!("-{pattern}")), - 2148
); - 2149
if policy.mcp_allow.as_ref().is_some_and(|a| a.is_empty()) { - 2150
rules.push("-mcp".into()); - 2151
} - 2152
rules.extend( - 2153
policy - 2154
.mcp_deny - 2155
.iter() - 2156
.map(|pattern| format!("-mcp({pattern})")), - 2157
); - 2158
rules - 2159
} - 2160
- 2161
pub fn memory_write_allowed(&self) -> bool { - 2162
if !self.channel_tool_allowed("remember") { - 2163
return false; - 2164
} - 2165
let rules = self.channel_permission_rules(); - 2166
let Ok(engine) = self.build_permission_engine(&rules) else { - 2167
return false; - 2168
}; - 2169
let mode = match self.effective_permission_mode() { - 2170
vak_config::PermissionMode::ReadOnly => vak_permission::Mode::ReadOnly, - 2171
vak_config::PermissionMode::WorkspaceWrite => vak_permission::Mode::WorkspaceWrite, - 2172
vak_config::PermissionMode::FullAccess => vak_permission::Mode::FullAccess, - 2173
}; - 2174
matches!( - 2175
engine.evaluate("remember", &serde_json::json!({}), mode, &self.inner.cwd), - 2176
vak_permission::Decision::Allow - 2177
) - 2178
} - 2179
- 2180
fn policy_matches(patterns: &[String], value: &str) -> bool { - 2181
patterns.iter().any(|pattern| { - 2182
globset::Glob::new(pattern) - 2183
.ok() - 2184
.is_some_and(|glob| glob.compile_matcher().is_match(value)) - 2185
}) - 2186
} - 2187
- 2188
fn allowed_by(allow: &Option<Vec<String>>, deny: &[String], value: &str) -> bool { - 2189
!Self::policy_matches(deny, value) - 2190
&& allow - 2191
.as_ref() - 2192
.is_none_or(|patterns| Self::policy_matches(patterns, value)) - 2193
} - 2194
- 2195
fn filter_mcp(&self, mut config: vak_config::McpConfig) -> vak_config::McpConfig { - 2196
let Some(policy) = self.channel_policy() else { - 2197
return config; - 2198
}; - 2199
config.servers.retain(|name, _| { - 2200
let server_pattern = format!("{name}/*"); - 2201
if Self::policy_matches(&policy.mcp_deny, &server_pattern) { - 2202
return false; - 2203
} - 2204
policy.mcp_allow.as_ref().is_none_or(|allow| { - 2205
Self::policy_matches(allow, &server_pattern) - 2206
|| allow - 2207
.iter() - 2208
.any(|pattern| pattern.starts_with(&format!("{name}/"))) - 2209
}) - 2210
}); - 2211
// Restrictive only: a channel can force a server's network off, but - 2212
// there is no matching grant — a server the config itself denies - 2213
// network to stays denied no matter what a channel policy says. - 2214
// Same pattern shape as mcp_allow/mcp_deny above (`name/*`). - 2215
for (name, server) in config.servers.iter_mut() { - 2216
let server_pattern = format!("{name}/*"); - 2217
if server.network && Self::policy_matches(&policy.mcp_network_deny, &server_pattern) { - 2218
server.network = false; - 2219
} - 2220
} - 2221
config - 2222
} - 2223
- 2224
/// Resolve `effective_mcp()` into the shape `vak_mcp::McpManager` wants: - 2225
/// `${VAR}` in env values expanded through the standard secret path, - 2226
/// fail-closed per server (an unresolved reference drops that server - 2227
/// rather than starting it half-configured). Shared by the per-turn - 2228
/// tool list and `mcp_manager()` so the two never resolve servers two - 2229
/// different ways. - 2230
fn resolved_mcp_servers(&self) -> Vec<(String, vak_mcp::ServerConfig)> { - 2231
self.effective_mcp() - 2232
.servers - 2233
.into_iter() - 2234
.filter_map(|(name, s)| { - 2235
let mut env = Vec::with_capacity(s.env.len()); - 2236
for (k, v) in &s.env { - 2237
match interpolate_env_var_with(v, |key| self.mcp_secret(key)) { - 2238
Some(resolved) => env.push((k.clone(), resolved)), - 2239
None => { - 2240
eprintln!("[mcp] server '{name}' skipped: unresolved environment variable in '{v}' (define it in .env)"); - 2241
return None; - 2242
} - 2243
} - 2244
} - 2245
Some(( - 2246
name, - 2247
vak_mcp::ServerConfig { - 2248
command: s.command, - 2249
args: s.args, - 2250
env, - 2251
network: s.network, - 2252
}, - 2253
)) - 2254
}) - 2255
.collect() - 2256
} - 2257
- 2258
/// Long-lived `McpManager` for this Core, reused across turns instead - 2259
/// of rebuilt per turn — `McpManager::get` caches one live connection - 2260
/// per server, so a manager rebuilt every turn meant every turn that - 2261
/// touched MCP respawned every configured server's process from - 2262
/// scratch. Returns `None` when no servers are configured. - 2263
/// - 2264
/// Keyed by a fingerprint of the resolved server set: a runtime - 2265
/// `set_mcp_servers` call or a plugin being enabled/disabled changes - 2266
/// what `effective_mcp()` returns, and the fingerprint mismatch swaps - 2267
/// in a fresh manager (dropping the old one, which shuts its clients - 2268
/// down on drop) rather than serving stale servers indefinitely. - 2269
/// - 2270
/// This only constructs the pool — no I/O, no spawn, no warm-up. A server - 2271
/// starts when a model's `mcp` call first needs it, and the pool shuts it - 2272
/// down again after `vak_mcp::IDLE_TTL` unused (AGENTS.md invariant 25). - 2273
fn mcp_manager(&self) -> Option<Arc<vak_mcp::McpManager>> { - 2274
let servers = self.resolved_mcp_servers(); - 2275
if servers.is_empty() { - 2276
*self - 2277
.inner - 2278
.mcp_cache - 2279
.lock() - 2280
.unwrap_or_else(std::sync::PoisonError::into_inner) = None; - 2281
return None; - 2282
} - 2283
let fp = mcp_fingerprint(&servers); - 2284
{ - 2285
let cache = self - 2286
.inner - 2287
.mcp_cache - 2288
.lock() - 2289
.unwrap_or_else(std::sync::PoisonError::into_inner); - 2290
if let Some(c) = cache.as_ref() - 2291
&& c.fingerprint == fp - 2292
{ - 2293
return Some(c.manager.clone()); - 2294
} - 2295
} - 2296
// The pool reports what demand taught it (a catalog learned, a - 2297
// failure recorded or cleared) and forwards a live server's - 2298
// `notifications/tools/list_changed`; both become registry hints, so - 2299
// the next turn sees the change. The loop is level-triggered, so a - 2300
// lost hint costs one tick of latency, never correctness. - 2301
let manager = vak_mcp::McpManager::new_sandboxed( - 2302
servers.into_iter().collect(), - 2303
self.inner.cwd.clone(), - 2304
self.build_sandbox(), - 2305
); - 2306
// Without a reactor (synchronous prompt assembly, one-shot tooling) - 2307
// nothing can be spawned or observed, so there is nothing to wire. - 2308
let (manager, wiring) = match tokio::runtime::Handle::try_current() { - 2309
Ok(_) => { - 2310
let (observed_tx, observed_rx) = tokio::sync::mpsc::unbounded_channel(); - 2311
let (notify_tx, notify_rx) = tokio::sync::mpsc::unbounded_channel(); - 2312
( - 2313
manager - 2314
.with_observer(observed_tx) - 2315
.with_notifications(notify_tx), - 2316
Some((observed_rx, notify_rx)), - 2317
) - 2318
} - 2319
Err(_) => (manager, None), - 2320
}; - 2321
let manager = Arc::new(manager); - 2322
if let Some((mut observed_rx, mut notify_rx)) = wiring { - 2323
let registry = self.capability_registry(); - 2324
let pool = Arc::downgrade(&manager); - 2325
tokio::spawn(async move { - 2326
loop { - 2327
let server = tokio::select! { - 2328
Some(server) = observed_rx.recv() => server, - 2329
Some((server, notification)) = notify_rx.recv() => { - 2330
if !notification.invalidates_tools() { - 2331
continue; - 2332
} - 2333
match pool.upgrade() { - 2334
// Forgetting announces, which arrives on - 2335
// `observed_rx` and hints from there. - 2336
Some(pool) => pool.forget_catalog(&server), - 2337
None => return, - 2338
} - 2339
continue; - 2340
} - 2341
else => return, - 2342
}; - 2343
registry.hint(capability::Hint::ServerObserved(server)); - 2344
} - 2345
}); - 2346
} - 2347
*self - 2348
.inner - 2349
.mcp_cache - 2350
.lock() - 2351
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(McpCache { - 2352
fingerprint: fp, - 2353
manager: manager.clone(), - 2354
}); - 2355
Some(manager) - 2356
} - 2357
- 2358
/// Replace lifecycle hooks for subsequent turns without restarting the - 2359
/// desktop/server process. Persistence is owned by the server surface. - 2360
pub fn set_hooks(&self, hooks: Vec<vak_config::HookConfig>) { - 2361
self.inner - 2362
.hooks_runtime_pinned - 2363
.store(true, std::sync::atomic::Ordering::Release); - 2364
self.replace_hooks(hooks); - 2365
} - 2366
- 2367
pub fn apply_persisted_hooks(&self, hooks: Vec<vak_config::HookConfig>) { - 2368
self.replace_hooks(hooks); - 2369
self.inner - 2370
.hooks_runtime_pinned - 2371
.store(false, std::sync::atomic::Ordering::Release); - 2372
} - 2373
- 2374
fn replace_hooks(&self, hooks: Vec<vak_config::HookConfig>) { - 2375
if let Ok(mut current) = self.inner.hooks_override.lock() { - 2376
*current = Some(hooks); - 2377
} - 2378
} - 2379
- 2380
pub fn effective_hooks(&self) -> Vec<vak_config::HookConfig> { - 2381
// Hooks are host commands, outside the brokered worker sandbox. - 2382
// A task-copy run may not inherit one from Shared configuration. - 2383
if self.task_copy_boundary { - 2384
return Vec::new(); - 2385
} - 2386
let hooks = if let Ok(current) = self.inner.hooks_override.lock() - 2387
&& let Some(hooks) = current.as_ref() - 2388
{ - 2389
hooks.clone() - 2390
} else { - 2391
self.inner.config.hooks.clone() - 2392
}; - 2393
let mut hooks = hooks; - 2394
for root in self.capability_roots() { - 2395
let store = vak_plugin::PluginStore::new(root.path); - 2396
if let Ok(plugin_hooks) = store.enabled_hooks() { - 2397
hooks.extend(plugin_hooks.into_iter().map(|(_plugin, hook)| { - 2398
vak_config::HookConfig { - 2399
event: hook.event, - 2400
matcher: hook.matcher, - 2401
command: hook.command, - 2402
timeout_ms: hook.timeout_ms, - 2403
enabled: true, - 2404
failure_mode: Some("open".into()), - 2405
} - 2406
})); - 2407
} - 2408
} - 2409
let Some(policy) = self.channel_policy() else { - 2410
return hooks; - 2411
}; - 2412
hooks - 2413
.into_iter() - 2414
.filter(|hook| { - 2415
let identity = format!("{}/{}", hook.event, hook.command); - 2416
Self::allowed_by(&policy.hooks_allow, &policy.hooks_deny, &identity) - 2417
}) - 2418
.collect() - 2419
} - 2420
- 2421
pub fn set_theme(&self, theme: String) { - 2422
self.inner - 2423
.theme_runtime_pinned - 2424
.store(true, std::sync::atomic::Ordering::Release); - 2425
if let Ok(mut t) = self.inner.theme_override.lock() { - 2426
*t = Some(theme); - 2427
} - 2428
} - 2429
- 2430
pub fn apply_persisted_theme(&self, theme: String) { - 2431
Self::write_override(&self.inner.theme_override, Some(theme)); - 2432
self.inner - 2433
.theme_runtime_pinned - 2434
.store(false, std::sync::atomic::Ordering::Release); - 2435
} - 2436
- 2437
/// Live-effective `[memory]` toggles (docs/design/23-memory.md): a - 2438
/// PATCH-applied override when one has been set this process's - 2439
/// lifetime, else whatever was persisted at construction/last refresh. - 2440
/// Every tool-registration and reflection call site must read through - 2441
/// these, never `self.inner.config.memory.*` directly, or a live PATCH - 2442
/// would silently do nothing until the process restarts. - 2443
pub fn effective_memory_search_enabled(&self) -> bool { - 2444
Self::read_override(&self.inner.memory_search_enabled_override) - 2445
.unwrap_or(self.inner.config.memory.search_enabled) - 2446
} - 2447
- 2448
pub fn effective_memory_write_enabled(&self) -> bool { - 2449
Self::read_override(&self.inner.memory_write_enabled_override) - 2450
.unwrap_or(self.inner.config.memory.write_enabled) - 2451
} - 2452
- 2453
pub fn effective_memory_reflection(&self) -> bool { - 2454
Self::read_override(&self.inner.memory_reflection_override) - 2455
.unwrap_or(self.inner.config.memory.reflection) - 2456
} - 2457
- 2458
pub fn effective_memory_skill_proposals(&self) -> bool { - 2459
Self::read_override(&self.inner.memory_skill_proposals_override) - 2460
.unwrap_or(self.inner.config.memory.skill_proposals) - 2461
} - 2462
- 2463
/// Set the live `[memory]` overrides all at once — used both by the - 2464
/// admin/desktop PATCH handler applying an explicit change and by - 2465
/// `refresh_persisted_preferences` picking up a value another process - 2466
/// wrote to disk. - 2467
pub fn apply_persisted_memory( - 2468
&self, - 2469
search_enabled: bool, - 2470
write_enabled: bool, - 2471
reflection: bool, - 2472
skill_proposals: bool, - 2473
) { - 2474
Self::write_override( - 2475
&self.inner.memory_search_enabled_override, - 2476
Some(search_enabled), - 2477
); - 2478
Self::write_override( - 2479
&self.inner.memory_write_enabled_override, - 2480
Some(write_enabled), - 2481
); - 2482
Self::write_override(&self.inner.memory_reflection_override, Some(reflection)); - 2483
Self::write_override( - 2484
&self.inner.memory_skill_proposals_override, - 2485
Some(skill_proposals), - 2486
); - 2487
} - 2488
- 2489
/// Whether worker delegation (the `task` tool) is available right - 2490
/// now — live-effective, same shape as the memory accessors above. - 2491
pub fn effective_workers(&self) -> bool { - 2492
Self::read_override(&self.inner.workers_override).unwrap_or(self.inner.config.workers) - 2493
} - 2494
- 2495
pub fn apply_persisted_workers(&self, enabled: bool) { - 2496
Self::write_override(&self.inner.workers_override, Some(enabled)); - 2497
} - 2498
- 2499
/// Whether bounded web fetch is available right now — live-effective. - 2500
pub fn effective_web_fetch(&self) -> bool { - 2501
Self::read_override(&self.inner.web_fetch_override) - 2502
.unwrap_or(self.inner.config.tools.web_fetch) - 2503
} - 2504
- 2505
/// Whether headless browse is available right now — live-effective. - 2506
pub fn effective_browse(&self) -> bool { - 2507
Self::read_override(&self.inner.browse_override).unwrap_or(self.inner.config.tools.browse) - 2508
} - 2509
- 2510
/// Live override setter for tools (web_fetch, browse). - 2511
pub fn apply_persisted_tools(&self, web_fetch: bool, browse: bool) { - 2512
Self::write_override(&self.inner.web_fetch_override, Some(web_fetch)); - 2513
Self::write_override(&self.inner.browse_override, Some(browse)); - 2514
} - 2515
- 2516
/// Whether commitments are enabled right now — live-effective. - 2517
pub fn effective_commitment(&self) -> bool { - 2518
Self::read_override(&self.inner.commitment_override) - 2519
.unwrap_or(self.inner.config.commitment.enabled) - 2520
} - 2521
- 2522
/// Live override setter for commitment enabled toggle. - 2523
pub fn apply_persisted_commitment(&self, enabled: bool) { - 2524
Self::write_override(&self.inner.commitment_override, Some(enabled)); - 2525
} - 2526
- 2527
/// The `[finops]` config, with any live cap override substituted in — - 2528
/// pass this to [`finops::CoreSpendGate::new`] instead of - 2529
/// `self.config().finops` directly, or a PATCH-set cap would never - 2530
/// actually bind. - 2531
pub fn effective_finops(&self) -> vak_config::FinopsResolved { - 2532
let mut finops = self.inner.config.finops.clone(); - 2533
if let Some(run) = Self::read_override(&self.inner.finops_max_run_usd_override) { - 2534
finops.max_run_usd = run; - 2535
} - 2536
if let Some(day) = Self::read_override(&self.inner.finops_max_day_usd_override) { - 2537
finops.max_day_usd = day; - 2538
} - 2539
finops - 2540
} - 2541
- 2542
/// The spend gate for `session_id`, built once and reused for every - 2543
/// subsequent turn of that session (docs/design/15-reliability.md). Rebuilding - 2544
/// a fresh gate per turn used to reset `max_run_usd`'s in-memory spend - 2545
/// counter to zero on every message — this cache is what makes the run - 2546
/// cap actually span the whole run rather than a single turn. Live cap - 2547
/// edits (`PATCH /finops`) are picked up on the next call via - 2548
/// `refresh_caps`, without disturbing spend already tallied. - 2549
fn spend_gate_for(&self, session_id: &str) -> Arc<finops::CoreSpendGate> { - 2550
let finops = self.effective_finops(); - 2551
let mut gates = self - 2552
.inner - 2553
.spend_gates - 2554
.lock() - 2555
.unwrap_or_else(std::sync::PoisonError::into_inner); - 2556
if let Some(gate) = gates.get(session_id) { - 2557
gate.refresh_caps(&finops); - 2558
return gate.clone(); - 2559
} - 2560
let gate = Arc::new(finops::CoreSpendGate::with_shared_day_budget( - 2561
// `self.sessions_home()`, not the raw `inner.sessions_home` - 2562
// field — the latter ignores `set_sessions_home` (the SDK - 2563
// seam tests/embedded runtimes use to relocate storage), so - 2564
// the ledger would silently keep writing to the original - 2565
// location. The reflection call site already got this right; - 2566
// the per-turn call site this replaces did not. - 2567
&self.shared_data_home(), - 2568
&finops, - 2569
self.inner.day_budget.clone(), - 2570
)); - 2571
gates.insert(session_id.to_string(), gate.clone()); - 2572
gate - 2573
} - 2574
- 2575
/// Drop a session's spend gate (and the run-spend it was tracking) - 2576
/// once the session is done, so `spend_gates` doesn't grow forever - 2577
/// across the lifetime of a long-running process. Safe to call even - 2578
/// when no gate was ever built for `session_id`. - 2579
pub fn forget_spend_gate(&self, session_id: &str) { - 2580
self.inner - 2581
.spend_gates - 2582
.lock() - 2583
.unwrap_or_else(std::sync::PoisonError::into_inner) - 2584
.remove(session_id); - 2585
} - 2586
- 2587
pub fn effective_finops_max_run_usd(&self) -> Option<f64> { - 2588
Self::read_override(&self.inner.finops_max_run_usd_override) - 2589
.unwrap_or(self.inner.config.finops.max_run_usd) - 2590
} - 2591
- 2592
pub fn effective_finops_max_day_usd(&self) -> Option<f64> { - 2593
Self::read_override(&self.inner.finops_max_day_usd_override) - 2594
.unwrap_or(self.inner.config.finops.max_day_usd) - 2595
} - 2596
- 2597
/// `None` for either cap leaves it at its current effective value — - 2598
/// same "absent means don't touch" convention `apply_persisted_memory` - 2599
/// uses, except here the value being set/kept is itself an - 2600
/// `Option<f64>` (a cap can legitimately be cleared to "none"). - 2601
pub fn apply_persisted_finops_caps( - 2602
&self, - 2603
max_run_usd: Option<Option<f64>>, - 2604
max_day_usd: Option<Option<f64>>, - 2605
) { - 2606
if let Some(run) = max_run_usd { - 2607
Self::write_override(&self.inner.finops_max_run_usd_override, Some(run)); - 2608
} - 2609
if let Some(day) = max_day_usd { - 2610
Self::write_override(&self.inner.finops_max_day_usd_override, Some(day)); - 2611
} - 2612
} - 2613
- 2614
/// Refresh every non-security persisted preference. Permission mode is - 2615
/// returned to the server control plane so it can revoke in-flight - 2616
/// capabilities before applying a changed value. - 2617
pub fn refresh_persisted_preferences(&self) -> Result<vak_config::PermissionMode, CoreError> { - 2618
let config = vak_config::load_with_trust(&self.inner.cwd, self.inner.trust_project_config)?; - 2619
let current_route = self.effective_route(); - 2620
if !current_route.runtime_pinned { - 2621
let route = route_from_config(&self.inner.cwd, &config, false); - 2622
if route != current_route { - 2623
self.replace_route(route); - 2624
} - 2625
} - 2626
if !self - 2627
.inner - 2628
.max_turns_runtime_pinned - 2629
.load(std::sync::atomic::Ordering::Acquire) - 2630
{ - 2631
self.apply_persisted_max_turns(config.max_turns); - 2632
} - 2633
Self::write_override( - 2634
&self.inner.evidence_max_age_override, - 2635
Some(config.intent.evidence_max_age_secs), - 2636
); - 2637
if !self - 2638
.inner - 2639
.theme_runtime_pinned - 2640
.load(std::sync::atomic::Ordering::Acquire) - 2641
{ - 2642
self.apply_persisted_theme(config.ui.theme.clone()); - 2643
} - 2644
if !self - 2645
.inner - 2646
.mcp_runtime_pinned - 2647
.load(std::sync::atomic::Ordering::Acquire) - 2648
{ - 2649
self.apply_persisted_mcp_servers(config.mcp.clone()); - 2650
} - 2651
if !self - 2652
.inner - 2653
.hooks_runtime_pinned - 2654
.load(std::sync::atomic::Ordering::Acquire) - 2655
{ - 2656
self.apply_persisted_hooks(config.hooks.clone()); - 2657
} - 2658
self.apply_persisted_capability_inheritance(config.capabilities.clone()); - 2659
self.apply_persisted_memory( - 2660
config.memory.search_enabled, - 2661
config.memory.write_enabled, - 2662
config.memory.reflection, - 2663
config.memory.skill_proposals, - 2664
); - 2665
self.apply_persisted_workers(config.workers); - 2666
self.apply_persisted_finops_caps( - 2667
Some(config.finops.max_run_usd), - 2668
Some(config.finops.max_day_usd), - 2669
); - 2670
self.apply_persisted_work(config.work.clone()); - 2671
self.apply_persisted_tools(config.tools.web_fetch, config.tools.browse); - 2672
self.apply_persisted_commitment(config.commitment.enabled); - 2673
self.apply_persisted_approval_mode(config.approval_mode); - 2674
self.apply_persisted_plugins(config.plugins.clone()); - 2675
Ok(config.permission_mode) - 2676
} - 2677
- 2678
pub fn effective_evidence_max_age_secs(&self) -> i64 { - 2679
Self::read_override(&self.inner.evidence_max_age_override) - 2680
.unwrap_or(self.inner.config.intent.evidence_max_age_secs) - 2681
} - 2682
- 2683
/// Derive a scoped allow rule from a call that was just approved, and - 2684
/// persist it — the "always allow this" half of an approval. - 2685
/// - 2686
/// Round-tripped before it is written: the derived spec must parse AND - 2687
/// must match the very call it came from. A rule that does not cover its - 2688
/// own triggering call would silently grant something else, and a rule - 2689
/// nobody can trace back to a decision is worse than no rule. - 2690
/// - 2691
/// Returns the spec that was stored, so a surface can show the operator - 2692
/// exactly what they just granted rather than "remembered". - 2693
pub fn learn_from_call( - 2694
&self, - 2695
tool: &str, - 2696
args: &serde_json::Value, - 2697
) -> Result<String, CoreError> { - 2698
let Some(spec) = scoped_allow_rule(tool, args) else { - 2699
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2700
path: std::path::PathBuf::from(PERMISSIONS_LOCAL_FILE), - 2701
source: std::io::Error::other(format!( - 2702
"'{tool}' cannot be narrowed to a safe rule from this call; \ - 2703
approve it each time instead" - 2704
)), - 2705
})); - 2706
}; - 2707
let rule = vak_permission::Rule::parse(&spec).map_err(CoreError::Rule)?; - 2708
if !rule.matches(tool, args) { - 2709
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2710
path: std::path::PathBuf::from(PERMISSIONS_LOCAL_FILE), - 2711
source: std::io::Error::other(format!( - 2712
"derived rule '{spec}' does not match the call it came from" - 2713
)), - 2714
})); - 2715
} - 2716
self.learn_allow_rule(&spec)?; - 2717
Ok(spec) - 2718
} - 2719
- 2720
/// Persists a learned allow rule to `.vak/permissions.local.toml` - 2721
/// (and this process's in-memory engine inputs). Trusted workspaces only: - 2722
/// an untrusted session must not be able to write grant files. Rules are - 2723
/// severity-aggregated by the engine, so a learned Allow can never - 2724
/// shadow an explicit Deny from any config layer. - 2725
pub fn learn_allow_rule(&self, spec: &str) -> Result<(), CoreError> { - 2726
vak_permission::Rule::parse(spec).map_err(CoreError::Rule)?; - 2727
if !self.inner.trust_project_config { - 2728
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2729
path: std::path::PathBuf::from(PERMISSIONS_LOCAL_FILE), - 2730
source: std::io::Error::other( - 2731
"untrusted workspace: refusing to persist permission rules", - 2732
), - 2733
})); - 2734
} - 2735
let path = self.inner.cwd.join(PERMISSIONS_LOCAL_FILE); - 2736
vak_config::file_update::update_file(&path, |current| { - 2737
let mut document = match current { - 2738
Some(text) => toml::from_str::<toml::Table>(text).map_err(|source| { - 2739
CoreError::Config(vak_config::ConfigError::Parse { - 2740
path: path.clone(), - 2741
source, - 2742
}) - 2743
})?, - 2744
None => toml::Table::new(), - 2745
}; - 2746
let allow = document - 2747
.entry("allow") - 2748
.or_insert_with(|| toml::Value::Array(Vec::new())); - 2749
let Some(allow) = allow.as_array_mut() else { - 2750
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2751
path: path.clone(), - 2752
source: std::io::Error::other("`allow` is not an array"), - 2753
})); - 2754
}; - 2755
if !allow.iter().any(|rule| rule.as_str() == Some(spec)) { - 2756
allow.push(toml::Value::String(spec.to_string())); - 2757
} - 2758
let rules: Vec<String> = allow - 2759
.iter() - 2760
.filter_map(|rule| rule.as_str().map(ToOwned::to_owned)) - 2761
.collect(); - 2762
let body = toml::to_string_pretty(&document).map_err(|error| { - 2763
CoreError::Session(vak_session::SessionError::Io(std::io::Error::other( - 2764
error.to_string(), - 2765
))) - 2766
})?; - 2767
// Published while the file lock is held, so the engine inputs - 2768
// never fall behind a rule another approval just wrote. - 2769
if let Ok(mut extra) = self.inner.extra_allow.lock() { - 2770
*extra = rules; - 2771
} - 2772
Ok(( - 2773
Some(format!( - 2774
"# Learned 'always allow' rules — written when you press [p] on an approval.\n{body}" - 2775
)), - 2776
(), - 2777
)) - 2778
}) - 2779
.map_err(|error| match error { - 2780
vak_config::file_update::UpdateError::Io { source, .. } => { - 2781
CoreError::Session(vak_session::SessionError::Io(source)) - 2782
} - 2783
vak_config::file_update::UpdateError::Edit(error) => error, - 2784
}) - 2785
} - 2786
- 2787
pub fn extra_allow_snapshot(&self) -> Vec<String> { - 2788
self.inner - 2789
.extra_allow - 2790
.lock() - 2791
.ok() - 2792
.map(|e| e.clone()) - 2793
.unwrap_or_default() - 2794
} - 2795
- 2796
pub fn effective_theme(&self) -> String { - 2797
Self::read_override(&self.inner.theme_override) - 2798
.unwrap_or_else(|| self.inner.config.ui.theme.clone()) - 2799
} - 2800
- 2801
pub fn effective_permission_mode(&self) -> vak_config::PermissionMode { - 2802
Self::read_override(&self.inner.mode_override).unwrap_or(self.inner.config.permission_mode) - 2803
} - 2804
- 2805
pub fn cwd(&self) -> &PathBuf { - 2806
&self.inner.cwd - 2807
} - 2808
- 2809
/// Bind this turn to the chat it's replying into, as `<surface>:<chat>` - 2810
/// (the same shape `deliver_to` already uses everywhere). Cheap: an - 2811
/// `Arc` bump plus one `String`, so callers can clone-and-set per - 2812
/// inbound message without touching the shared workspace state the - 2813
/// `Arc<CoreInner>` carries. - 2814
pub fn with_default_deliver_to(mut self, target: Option<String>) -> Self { - 2815
self.default_deliver_to = target; - 2816
self - 2817
} - 2818
- 2819
/// Name the surface this turn runs on, so the system prompt can say where - 2820
/// the reply will be read. Cheap in the same way - 2821
/// [`Core::with_default_deliver_to`] is — an `Arc` bump and one small - 2822
/// value — so the gateway can clone-and-stamp per inbound message. - 2823
pub fn with_surface(mut self, surface: Surface) -> Self { - 2824
self.surface = surface; - 2825
self - 2826
} - 2827
- 2828
/// Use only for a child Core whose cwd is a freshly prepared task copy. - 2829
/// It caps the turn at workspace-write and removes host temp write grants - 2830
/// from the native worker sandbox. It never changes shared Core state. - 2831
pub fn with_task_copy_boundary(mut self) -> Self { - 2832
self.task_copy_boundary = true; - 2833
self - 2834
} - 2835
- 2836
/// Marks Office files in this task copy as new to the workspace it was - 2837
/// made from (see the `new_documents` field). - 2838
pub fn with_new_documents(mut self, paths: Vec<String>) -> Self { - 2839
self.new_documents = Arc::new(paths); - 2840
self - 2841
} - 2842
- 2843
pub fn surface(&self) -> &Surface { - 2844
&self.surface - 2845
} - 2846
- 2847
/// Stamp this turn's answerability from the approver that will actually - 2848
/// serve it. Cheap in the same way [`Core::with_surface`] is, so a host - 2849
/// can clone-and-set per inbound message. - 2850
/// - 2851
/// Takes the approver rather than a bare `bool` deliberately. The two - 2852
/// used to be independent — a host set the flag by hand and installed an - 2853
/// approver separately, with a comment asking them to agree — and the - 2854
/// scheduler proved what that costs: it installed an approver nobody was - 2855
/// subscribed to while leaving the flag at its `true` default, so every - 2856
/// unattended routine was told a gated capability was usable and then - 2857
/// blocked on a gate no one would ever answer. Deriving the flag from - 2858
/// the object makes that disagreement unrepresentable. - 2859
pub fn with_approver(mut self, approver: &dyn vak_agent::Approver) -> Self { - 2860
self.approver_answerable = approver.answerable(); - 2861
self - 2862
} - 2863
- 2864
/// The same stamp for a host that has not constructed its approver yet - 2865
/// but already knows which one it will build — the gateway, whose - 2866
/// `GatewayApprover` needs a session id that does not exist until the - 2867
/// turn starts, and which must freeze the prompt before then. - 2868
/// - 2869
/// Prefer [`Core::with_approver`]. Anything set here is reconciled - 2870
/// against the real approver when the run starts - 2871
/// ([`Core::reconcile_answerability`]), so a wrong value is corrected - 2872
/// and recorded rather than silently believed. - 2873
pub fn with_approver_answerable(mut self, answerable: bool) -> Self { - 2874
self.approver_answerable = answerable; - 2875
self - 2876
} - 2877
- 2878
pub fn approver_answerable(&self) -> bool { - 2879
self.approver_answerable - 2880
} - 2881
- 2882
/// Last line of defence for the stamp above: compare what this turn was - 2883
/// told about its approver against the approver it actually got, and - 2884
/// take the approver's word. - 2885
/// - 2886
/// The prompt is already frozen by the time a run starts, so a - 2887
/// disagreement cannot be un-said to the model — but it can be recorded, - 2888
/// and it can be corrected for everything computed at dispatch (the - 2889
/// registry filter and the audit standings). An operator reading - 2890
/// `answerability_mismatch` in the security log is reading a real defect - 2891
/// in a hosting surface, not a configuration problem. - 2892
fn reconcile_answerability(&mut self, approver: Option<&Arc<dyn vak_agent::Approver>>) { - 2893
let actual = approver.map(|a| a.answerable()).unwrap_or(false); - 2894
if actual == self.approver_answerable { - 2895
return; - 2896
} - 2897
security_events::record( - 2898
&self.sessions_home(), - 2899
security_events::EventKind::ConfigChange, - 2900
"answerability_mismatch", - 2901
&format!( - 2902
"surface={} stamped={} installed_approver={}; using the approver", - 2903
self.surface.slug(), - 2904
self.approver_answerable, - 2905
actual - 2906
), - 2907
None, - 2908
); - 2909
self.approver_answerable = actual; - 2910
} - 2911
- 2912
/// This turn's capability standings: what the composed policy actually - 2913
/// permits, as opposed to what configuration declares. One computation, - 2914
/// read by the prompt, the tool registry, `doctor`, and the audit log, - 2915
/// so those four can never disagree about whether a capability works. - 2916
pub fn capability_standings(&self) -> Vec<reach::Standing> { - 2917
let Ok(engine) = self.build_permission_engine(&self.channel_permission_rules()) else { - 2918
return Vec::new(); - 2919
}; - 2920
let mode = match self.effective_permission_mode() { - 2921
vak_config::PermissionMode::ReadOnly => vak_permission::Mode::ReadOnly, - 2922
vak_config::PermissionMode::WorkspaceWrite => vak_permission::Mode::WorkspaceWrite, - 2923
vak_config::PermissionMode::FullAccess => vak_permission::Mode::FullAccess, - 2924
}; - 2925
let approval_mode = match self.effective_approval_mode() { - 2926
vak_config::ApprovalMode::Ask => vak_agent::ApprovalMode::Ask, - 2927
vak_config::ApprovalMode::ApproveSafe => vak_agent::ApprovalMode::ApproveSafe, - 2928
vak_config::ApprovalMode::AutoApprove => vak_agent::ApprovalMode::AutoApprove, - 2929
}; - 2930
let mut servers: Vec<String> = self.effective_mcp().servers.into_keys().collect(); - 2931
servers.sort(); - 2932
let registered = self.tool_names(); - 2933
let network: Vec<String> = NETWORK_TOOLS - 2934
.iter() - 2935
.filter(|tool| registered.iter().any(|name| name == *tool)) - 2936
.map(|tool| (*tool).to_string()) - 2937
.collect(); - 2938
let skills: Vec<String> = self.skills().into_iter().map(|s| s.name).collect(); - 2939
reach::standings(&reach::Probe { - 2940
engine: &engine, - 2941
mode, - 2942
approval_mode, - 2943
sandboxed: self.build_sandbox().is_some(), - 2944
cwd: &self.inner.cwd, - 2945
approver_answerable: self.approver_answerable, - 2946
mcp_servers: &servers, - 2947
network_tools: &network, - 2948
skills: &skills, - 2949
}) - 2950
} - 2951
- 2952
/// Select a named `prompts/agents/<name>` layer for this turn. - 2953
pub fn with_prompt_role(mut self, role: Option<String>) -> Self { - 2954
self.prompt_role = role.filter(|r| !r.trim().is_empty()); - 2955
self - 2956
} - 2957
- 2958
pub fn prompt_role(&self) -> Option<&str> { - 2959
self.prompt_role.as_deref() - 2960
} - 2961
- 2962
pub fn with_agent_identity(mut self, agent: Option<vak_session::types::AgentIdentity>) -> Self { - 2963
// `None` means the built-in Agent at every new admission. Keeping a - 2964
// concrete identity here prevents background, gateway, and resumed - 2965
// sessions from silently reverting to the old missing-header state. - 2966
self.agent_identity = Some(agent.unwrap_or_else(vak_agent_identity)); - 2967
self - 2968
} - 2969
- 2970
/// Bind a Core clone to one authorized conversation before admission. - 2971
/// This is deliberately clone-local: pooled workspace state must never - 2972
/// acquire one chat's audience or delivery destination. - 2973
pub fn with_conversation_context( - 2974
mut self, - 2975
context: Option<vak_session::types::ConversationContext>, - 2976
) -> Self { - 2977
self.conversation_context = context; - 2978
self - 2979
} - 2980
- 2981
pub fn conversation_context(&self) -> Option<&vak_session::types::ConversationContext> { - 2982
self.conversation_context.as_ref() - 2983
} - 2984
- 2985
pub fn agent_identity(&self) -> Option<&vak_session::types::AgentIdentity> { - 2986
self.agent_identity.as_ref() - 2987
} - 2988
- 2989
/// Attach caller-owned prompt layers (the gateway's bot and chat tiers). - 2990
/// Restrictive by construction: `resolve` folds guardrails in and lets a - 2991
/// narrower identity win, and neither can reach the code-owned blocks. - 2992
pub fn with_prompt_overlays(mut self, overlays: Vec<prompts::LayerInput>) -> Self { - 2993
self.prompt_overlays = Arc::new(overlays); - 2994
self - 2995
} - 2996
- 2997
/// Reopens an existing session ledger for resumed runs. - 2998
pub async fn open_session(&self, session_id: &str) -> Result<SessionLog, CoreError> { - 2999
self.refuse_trashed(session_id)?; - 3000
let path = vak_session::SessionPath::new_session_file(
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.