- 1649
lease.cancel(); - 1650
*lease = CancellationToken::new(); - 1651
} - 1652
self.inner - 1653
.mode_runtime_pinned - 1654
.store(pinned, std::sync::atomic::Ordering::Release); - 1655
Self::write_override(&self.inner.mode_override, Some(mode)); - 1656
} - 1657
- 1658
pub fn apply_persisted_permission_mode(&self, mode: vak_config::PermissionMode) { - 1659
self.replace_permission_mode(mode, false); - 1660
} - 1661
- 1662
/// A run's authority lease. A mode change cancels old leases before - 1663
/// publishing its new mode; a caller cannot revive one by resetting its - 1664
/// own cancellation token. - 1665
pub fn permission_lease(&self) -> CancellationToken { - 1666
self.inner - 1667
.permission_lease - 1668
.lock() - 1669
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1670
.child_token() - 1671
} - 1672
- 1673
/// Read only the persisted trust-resolved ceiling. Unlike preference - 1674
/// refresh this never touches live MCP clients or capability caches. - 1675
pub fn persisted_permission_ceiling(&self) -> Result<vak_config::PermissionMode, CoreError> { - 1676
Ok( - 1677
vak_config::load_with_trust(&self.inner.cwd, self.inner.trust_project_config)? - 1678
.permission_mode, - 1679
) - 1680
} - 1681
- 1682
/// Revoke a resident scoped mode when the workspace's persisted ceiling - 1683
/// has narrowed. A narrower channel pin remains intact; only a mode above - 1684
/// the new ceiling is replaced, and replacement cancels its old lease. - 1685
pub fn enforce_persisted_permission_ceiling(&self) -> Result<bool, CoreError> { - 1686
let ceiling = self.persisted_permission_ceiling()?; - 1687
if self.effective_permission_mode().rank() > ceiling.rank() { - 1688
self.replace_permission_mode(ceiling, false); - 1689
Ok(true) - 1690
} else { - 1691
Ok(false) - 1692
} - 1693
} - 1694
- 1695
pub fn permission_mode_runtime_pinned(&self) -> bool { - 1696
self.inner - 1697
.mode_runtime_pinned - 1698
.load(std::sync::atomic::Ordering::Acquire) - 1699
} - 1700
- 1701
/// The runtime-pinned permission mode, if one is active (see - 1702
/// `permission_mode_runtime_pinned`), so it can be carried over to a - 1703
/// freshly-resolved `Core` for another workspace/agent — otherwise a - 1704
/// user-pinned restriction (e.g. read-only) silently would not apply the - 1705
/// moment a different Agent's Core is resolved from disk config. - 1706
pub fn permission_mode_override_value(&self) -> Option<vak_config::PermissionMode> { - 1707
if self.permission_mode_runtime_pinned() { - 1708
Self::read_override(&self.inner.mode_override) - 1709
} else { - 1710
None - 1711
} - 1712
} - 1713
- 1714
pub fn effective_approval_mode(&self) -> vak_config::ApprovalMode { - 1715
Self::read_override(&self.inner.approval_mode_override) - 1716
.unwrap_or(self.inner.config.approval_mode) - 1717
} - 1718
- 1719
pub fn set_approval_mode(&self, mode: vak_config::ApprovalMode) { - 1720
Self::write_override(&self.inner.approval_mode_override, Some(mode)); - 1721
} - 1722
- 1723
pub fn apply_persisted_approval_mode(&self, mode: vak_config::ApprovalMode) { - 1724
Self::write_override(&self.inner.approval_mode_override, Some(mode)); - 1725
} - 1726
- 1727
/// The effective `(allow, ask, deny)` lists — the runtime override when - 1728
/// one has been applied, the loaded config otherwise. - 1729
/// - 1730
/// Everything that builds a permission engine reads rules through here, - 1731
/// so an edit persisted by `PUT /config/permissions` takes effect on the - 1732
/// next turn rather than the next process. - 1733
pub fn effective_permission_rules(&self) -> PermissionRuleLists { - 1734
Self::read_override(&self.inner.rules_override).unwrap_or_else(|| { - 1735
( - 1736
self.inner.config.allow.clone(), - 1737
self.inner.config.ask.clone(), - 1738
self.inner.config.deny.clone(), - 1739
) - 1740
}) - 1741
} - 1742
- 1743
pub fn apply_persisted_permission_rules( - 1744
&self, - 1745
allow: Vec<String>, - 1746
ask: Vec<String>, - 1747
deny: Vec<String>, - 1748
) { - 1749
Self::write_override(&self.inner.rules_override, Some((allow, ask, deny))); - 1750
} - 1751
- 1752
/// Build a permission engine from this `Core`'s effective rules. - 1753
/// - 1754
/// The single entry point every surface uses. Callers used to reach for - 1755
/// `build_engine_with(core.config(), …)` directly, which read the - 1756
/// immutable loaded config and so could not see a runtime rule change; - 1757
/// routing through the `Core` is what keeps "what the engine evaluates" - 1758
/// and "what the operator last set" the same answer. - 1759
pub fn build_permission_engine( - 1760
&self, - 1761
extra: &[String], - 1762
) -> Result<vak_permission::PermissionEngine, CoreError> { - 1763
let (allow, ask, deny) = self.effective_permission_rules(); - 1764
vak_permission::PermissionEngine::from_rule_strings(&rule_specs_from( - 1765
&allow, &ask, &deny, extra, - 1766
)) - 1767
.map(|engine| engine.with_presenting_tools(presentation_tools::presenting_tool_names())) - 1768
.map(|engine| { - 1769
if self.task_copy_boundary { - 1770
engine.restrict_tools(TASK_COPY_TOOLS) - 1771
} else { - 1772
engine - 1773
} - 1774
}) - 1775
.map_err(CoreError::Rule) - 1776
} - 1777
- 1778
/// Runtime sandbox-backend selection ("os", "docker", or config default - 1779
/// via None). Session-scoped like every other override; never persisted. - 1780
pub fn set_sandbox_backend(&self, backend: Option<String>) { - 1781
Self::write_override(&self.inner.sandbox_backend_override, backend); - 1782
} - 1783
- 1784
/// The runtime-pinned sandbox backend, if one is active, so it can be - 1785
/// carried over to a freshly-resolved `Core` for another workspace/agent - 1786
/// — otherwise a user-pinned backend (e.g. forcing "docker" for a - 1787
/// hardened run) silently would not apply the moment a different - 1788
/// Agent's Core is resolved from disk config. - 1789
pub fn sandbox_backend_override_value(&self) -> Option<String> { - 1790
Self::read_override(&self.inner.sandbox_backend_override) - 1791
} - 1792
- 1793
pub fn effective_sandbox_backend(&self) -> String { - 1794
Self::read_override(&self.inner.sandbox_backend_override) - 1795
.unwrap_or_else(|| self.inner.config.sandbox.backend.clone()) - 1796
} - 1797
- 1798
pub fn breaker(&self) -> Arc<vak_agent::CircuitBreaker> { - 1799
self.inner.breaker.clone() - 1800
} - 1801
- 1802
/// Runtime MCP server table replacement (trusted surfaces only). Takes - 1803
/// effect on the next turn; the cached capability section is dropped so - 1804
/// the next run re-discovers the new inventory. - 1805
pub fn set_mcp_servers(&self, config: vak_config::McpConfig) { - 1806
self.inner - 1807
.mcp_runtime_pinned - 1808
.store(true, std::sync::atomic::Ordering::Release); - 1809
self.replace_mcp(config); - 1810
} - 1811
- 1812
pub fn apply_persisted_mcp_servers(&self, config: vak_config::McpConfig) { - 1813
self.invalidate_mcp_cache(); - 1814
self.replace_mcp(config.clone()); - 1815
self.inner - 1816
.mcp_runtime_pinned - 1817
.store(false, std::sync::atomic::Ordering::Release); - 1818
self.capability_registry() - 1819
.hint(capability::Hint::ConfigChanged); - 1820
} - 1821
- 1822
pub fn invalidate_mcp_cache(&self) { - 1823
*self - 1824
.inner - 1825
.mcp_cache - 1826
.lock() - 1827
.unwrap_or_else(std::sync::PoisonError::into_inner) = None; - 1828
} - 1829
- 1830
fn replace_mcp(&self, config: vak_config::McpConfig) { - 1831
if let Ok(mut c) = self.inner.mcp_override.lock() { - 1832
*c = Some(config); - 1833
} - 1834
} - 1835
- 1836
/// **The** capability packet for a turn, for every surface. - 1837
/// - 1838
/// Every surface asks here, so the same question always gets the same - 1839
/// packet. - 1840
/// - 1841
/// One canonical way (AGENTS.md invariant 30): surfaces do not decide - 1842
/// this, admission does. A pass is offline — declarations plus what the - 1843
/// MCP pool has already observed — so admission reconciles when anything - 1844
/// changed and never waits on an integration (invariant 25). - 1845
pub async fn admitted_capabilities(&self) -> Vec<CapabilityDescriptor> { - 1846
let registry = self.capability_registry(); - 1847
if registry.current().await.epoch == 0 || registry.has_pending_changes().await { - 1848
registry.reconcile().await; - 1849
} - 1850
registry.current().await.descriptors() - 1851
} - 1852
- 1853
/// Fast live revocation check for presentation and other non-async - 1854
/// projections. Availability still follows the published epoch; this - 1855
/// check only answers whether a capability is forbidden right now. - 1856
pub fn capability_revoked(&self, kind: vak_session::types::CapabilityKind, name: &str) -> bool { - 1857
self.capability_registry() - 1858
.revoked_now(&capability::CapabilityId::new(kind, name)) - 1859
} - 1860
- 1861
/// A live session's admitted set, re-rendered against the current - 1862
/// registry. - 1863
/// - 1864
/// **Admission is unchanged**: the contract still decides what may be - 1865
/// called, and a capability the registry has since gained does not - 1866
/// appear here. What is refreshed is the *description* of something the - 1867
/// contract already admits — most visibly an MCP server's discovered - 1868
/// catalog, but equally a skill's summary or a command's template. - 1869
/// - 1870
/// This is the epoch re-bind from doc 41 invariant 6: capability changes - 1871
/// take effect at the next turn boundary of every live session, with no - 1872
/// restart and no rotation. Without it a session admitted while - 1873
/// discovery was still in flight carries the name-only MCP line for its - 1874
/// entire life, and under those two constraints "its entire life" has no - 1875
/// end. Matching is on the typed `(kind, name)` identity rather than on - 1876
/// rendered text, so it holds for every kind rather than the one whose - 1877
/// wording someone thought to grep for. - 1878
async fn rebound_capabilities( - 1879
&self, - 1880
_contract: &vak_session::types::FrozenContract, - 1881
) -> Vec<CapabilityDescriptor> { - 1882
self.admitted_capabilities().await - 1883
} - 1884
- 1885
pub fn effective_mcp(&self) -> vak_config::McpConfig { - 1886
let mut config = self - 1887
.inner - 1888
.mcp_override - 1889
.lock() - 1890
.ok() - 1891
.and_then(|c| c.clone()) - 1892
.unwrap_or_else(|| self.inner.config.mcp.clone()); - 1893
self.extend_enabled_plugin_mcp(&mut config); - 1894
self.filter_mcp(config) - 1895
} - 1896
- 1897
pub fn effective_capability_inheritance(&self) -> vak_config::CapabilityInheritanceResolved { - 1898
self.inner - 1899
.capabilities_override - 1900
.lock() - 1901
.ok() - 1902
.and_then(|value| value.clone()) - 1903
.unwrap_or_else(|| self.inner.config.capabilities.clone()) - 1904
} - 1905
- 1906
pub fn apply_persisted_capability_inheritance( - 1907
&self, - 1908
capabilities: vak_config::CapabilityInheritanceResolved, - 1909
) { - 1910
if let Ok(mut current) = self.inner.capabilities_override.lock() { - 1911
*current = Some(capabilities); - 1912
} - 1913
if let Ok(mut cache) = self.inner.mcp_cache.lock() { - 1914
*cache = None; - 1915
} - 1916
} - 1917
- 1918
fn shared_capability_root(&self) -> std::path::PathBuf { - 1919
vak_config::paths::default_workspace().join(".vak") - 1920
} - 1921
- 1922
/// Plugin package roots contributing skills and commands, tagged with the - 1923
/// provenance string inspection surfaces render. - 1924
fn enabled_plugin_skill_roots(&self) -> Vec<(std::path::PathBuf, String)> { - 1925
let mut out = Vec::new(); - 1926
for root in self.capability_roots() { - 1927
let Ok(enabled) = vak_plugin::PluginStore::new(&root.path).enabled() else { - 1928
continue; - 1929
}; - 1930
out.extend(enabled.into_iter().map(|plugin| { - 1931
( - 1932
plugin.package_path, - 1933
format!( - 1934
"plugin:{}:{}:{}", - 1935
root.scope.label(), - 1936
plugin.name, - 1937
plugin.trace_id - 1938
), - 1939
) - 1940
})); - 1941
} - 1942
out - 1943
} - 1944
- 1945
/// The capability roots this Core reads, workspace-local first so a - 1946
/// workspace-scoped skill, command, plugin, or hook shadows a shared one - 1947
/// of the same name. - 1948
/// - 1949
/// The shared root is dropped when `capabilities.inherit_plugins = false`, - 1950
/// and collapsed when the workspace IS the shared workspace. Every - 1951
/// capability lookup goes through here: this resolution was copied into - 1952
/// six call sites, one of which had already drifted to the opposite - 1953
/// ordering, and a shared-scope bug in one copy is invisible in the rest. - 1954
pub fn capability_roots(&self) -> Vec<CapabilityRoot> { - 1955
let shared = self.shared_capability_root(); - 1956
let workspace = self.inner.cwd.join(".vak"); - 1957
let mut roots = vec![CapabilityRoot { - 1958
path: workspace.clone(), - 1959
scope: CapabilityScope::Workspace, - 1960
}]; - 1961
if workspace != shared && self.effective_capability_inheritance().inherit_plugins { - 1962
roots.push(CapabilityRoot { - 1963
path: shared, - 1964
scope: CapabilityScope::Shared, - 1965
}); - 1966
} - 1967
roots - 1968
} - 1969
- 1970
/// Scan all plugin stores for packages whose skill descriptions reference - 1971
/// retired tool names (e.g. `python_eval`, `react_preview`). Returns each - 1972
/// `(plugin_name, retired_tool_names)` pair found. - 1973
/// - 1974
/// This is a **read-only** check: it never removes or disables plugins. - 1975
/// Removal is the job of `seed::cleanup_retired_plugins` during setup - 1976
/// (`vak setup seed` / `vak self update`). The check exists so a running - 1977
/// server or desktop session can surface a prominent warning and refuse - 1978
/// to advertise skills from retired-tool plugins in the capability - 1979
/// contract sent to the model (AGNS invariant 9: model catalogues are - 1980
/// discovered, never hardcoded; and invariant 29: pre-baseline or - 1981
/// retired state is refused, not partially read). - 1982
pub fn check_retired_plugins(&self) -> Vec<(String, Vec<String>)> { - 1983
let mut flagged = Vec::new(); - 1984
for root in self.capability_roots() { - 1985
if let Ok(store) = vak_plugin::PluginStore::new(&root.path).retired_plugins() { - 1986
flagged.extend(store); - 1987
} - 1988
} - 1989
flagged - 1990
} - 1991
- 1992
fn extend_enabled_plugin_mcp(&self, config: &mut vak_config::McpConfig) { - 1993
for root in self.capability_roots() { - 1994
let Ok(plugins) = vak_plugin::PluginStore::new(root.path).enabled() else { - 1995
continue; - 1996
}; - 1997
for plugin in plugins { - 1998
for relative in plugin.capabilities.mcp_manifests { - 1999
let path = plugin.package_path.join(&relative); - 2000
let Ok(bytes) = std::fs::read(&path) else { - 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
{
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.