- 1739
} - 1740
- 1741
pub fn project_path(cwd: &Path) -> PathBuf { - 1742
cwd.join(".vak/config.toml") - 1743
} - 1744
- 1745
/// Cheap, stat-only "has anything changed" signal for the global + project - 1746
/// config layers. Callers that hold a resolved value derived from these - 1747
/// files (e.g. `Core`'s cached `RouteSelection`) can compare fingerprints on - 1748
/// every access instead of re-parsing TOML, and only pay for a full re-load - 1749
/// when this value actually moves (docs/design/44-shared-config.md, - 1750
/// "Liveness"). - 1751
pub fn config_fingerprint(cwd: &Path) -> u64 { - 1752
let mut hash = 0xcbf29ce484222325_u64; - 1753
for path in [global_path(), Some(project_path(cwd))] - 1754
.into_iter() - 1755
.flatten() - 1756
{ - 1757
let (mtime_nanos, len) = std::fs::metadata(&path) - 1758
.and_then(|m| m.modified().map(|t| (t, m.len()))) - 1759
.map(|(t, len)| { - 1760
let nanos = t - 1761
.duration_since(std::time::UNIX_EPOCH) - 1762
.map(|d| d.as_nanos() as u64) - 1763
.unwrap_or(0); - 1764
(nanos, len) - 1765
}) - 1766
.unwrap_or((0, 0)); - 1767
for byte in mtime_nanos - 1768
.to_le_bytes() - 1769
.into_iter() - 1770
.chain(len.to_le_bytes()) - 1771
{ - 1772
hash ^= u64::from(byte); - 1773
hash = hash.wrapping_mul(0x100000001b3); - 1774
} - 1775
} - 1776
hash - 1777
} - 1778
- 1779
/// Initialize the project layer used by interactive clients. - 1780
/// - 1781
/// The file intentionally contains no copied global values. An empty project - 1782
/// layer inherits the user's global configuration through [`load_with_trust`], - 1783
/// so later changes to shared defaults reach projects that have not opted into - 1784
/// a local override. `create_new` also keeps two desktop launches from - 1785
/// overwriting a project config created by the other launch. - 1786
pub fn ensure_project_config(cwd: &Path) -> Result<PathBuf, ConfigError> { - 1787
let dir = cwd.join(".vak"); - 1788
std::fs::create_dir_all(&dir).map_err(|source| ConfigError::Write { - 1789
path: dir.clone(), - 1790
source, - 1791
})?; - 1792
let path = project_path(cwd); - 1793
let _guard = lock_config_files(); - 1794
match std::fs::OpenOptions::new() - 1795
.write(true) - 1796
.create_new(true) - 1797
.open(&path) - 1798
{ - 1799
Ok(mut file) => { - 1800
use std::io::Write; - 1801
file.write_all( - 1802
b"# Project-local overrides. Unset values inherit from the user config.\n", - 1803
) - 1804
.map_err(|source| ConfigError::Write { - 1805
path: path.clone(), - 1806
source, - 1807
})?; - 1808
} - 1809
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - 1810
Err(source) => { - 1811
return Err(ConfigError::Write { - 1812
path: path.clone(), - 1813
source, - 1814
}); - 1815
} - 1816
} - 1817
Ok(path) - 1818
} - 1819
- 1820
/// Atomically replace the set of MCP servers at one explicit configuration - 1821
/// scope. The caller selects either [`global_path`] or [`project_path`]; no - 1822
/// values are inferred from the process directory. A server missing from - 1823
/// `servers` is removed; a server that stays keeps every key the management - 1824
/// API does not own, and every other key in the file is preserved. - 1825
pub fn persist_mcp_servers( - 1826
path: &Path, - 1827
servers: &std::collections::BTreeMap<String, McpServerConfig>, - 1828
) -> Result<(), ConfigError> { - 1829
update_config_file(path, |document| { - 1830
let mcp = child_table(document, "mcp", path)?; - 1831
let mut existing = match mcp.remove("servers") { - 1832
Some(toml::Value::Table(existing)) => existing, - 1833
_ => toml::Table::new(), - 1834
}; - 1835
let entries = servers - 1836
.iter() - 1837
.map(|(name, server)| { - 1838
let mut entry = match existing.remove(name) { - 1839
Some(toml::Value::Table(entry)) => entry, - 1840
_ => toml::Table::new(), - 1841
}; - 1842
write_mcp_server(&mut entry, server); - 1843
(name.clone(), toml::Value::Table(entry)) - 1844
}) - 1845
.collect(); - 1846
mcp.insert("servers".into(), toml::Value::Table(entries)); - 1847
Ok(()) - 1848
}) - 1849
} - 1850
- 1851
/// Add, replace (`Some`) or remove (`None`) the one MCP server `name` at one - 1852
/// explicit configuration scope, leaving every other server as the file has - 1853
/// it. The read happens under the same lock as the write, so a change made - 1854
/// to another server in the meantime is not lost to a stale copy. - 1855
pub fn persist_mcp_server( - 1856
path: &Path, - 1857
name: &str, - 1858
server: Option<&McpServerConfig>, - 1859
) -> Result<(), ConfigError> { - 1860
update_config_file(path, |document| { - 1861
match server { - 1862
Some(server) => { - 1863
let servers = child_table(child_table(document, "mcp", path)?, "servers", path)?; - 1864
write_mcp_server(child_table(servers, name, path)?, server); - 1865
} - 1866
None => { - 1867
if let Some(servers) = document - 1868
.get_mut("mcp") - 1869
.and_then(toml::Value::as_table_mut) - 1870
.and_then(|mcp| mcp.get_mut("servers")) - 1871
.and_then(toml::Value::as_table_mut) - 1872
{ - 1873
servers.remove(name); - 1874
} - 1875
} - 1876
} - 1877
Ok(()) - 1878
}) - 1879
} - 1880
- 1881
/// Write the keys the management API owns onto one `[mcp.servers.<name>]` - 1882
/// entry. Any other key there — `serves`, which no API sets, or one this - 1883
/// version does not know — stays as the file had it; `serves` is written - 1884
/// only when the caller declares it. - 1885
fn write_mcp_server(entry: &mut toml::Table, server: &McpServerConfig) { - 1886
let strings = |values: &[String]| { - 1887
toml::Value::Array(values.iter().cloned().map(toml::Value::String).collect()) - 1888
}; - 1889
entry.insert( - 1890
"command".into(), - 1891
toml::Value::String(server.command.clone()), - 1892
); - 1893
entry.insert("args".into(), strings(&server.args)); - 1894
if server.env.is_empty() { - 1895
entry.remove("env"); - 1896
} else { - 1897
entry.insert( - 1898
"env".into(), - 1899
toml::Value::Table( - 1900
server - 1901
.env - 1902
.iter() - 1903
.map(|(key, value)| (key.clone(), toml::Value::String(value.clone()))) - 1904
.collect(), - 1905
), - 1906
); - 1907
} - 1908
if server.network { - 1909
entry.insert("network".into(), toml::Value::Boolean(true)); - 1910
} else { - 1911
entry.remove("network"); - 1912
} - 1913
if !server.serves.is_empty() { - 1914
entry.insert("serves".into(), strings(&server.serves)); - 1915
} - 1916
} - 1917
- 1918
/// Persist project capability inheritance switches without materializing any - 1919
/// inherited definitions into the project file. - 1920
pub fn persist_capability_inheritance( - 1921
path: &Path, - 1922
inherit_mcp: Option<bool>, - 1923
inherit_hooks: Option<bool>, - 1924
inherit_skills: Option<bool>, - 1925
inherit_commands: Option<bool>, - 1926
inherit_plugins: Option<bool>, - 1927
) -> Result<(), ConfigError> { - 1928
update_config_file(path, |document| { - 1929
let capabilities = child_table(document, "capabilities", path)?; - 1930
for (name, value) in [ - 1931
("inherit_mcp", inherit_mcp), - 1932
("inherit_hooks", inherit_hooks), - 1933
("inherit_skills", inherit_skills), - 1934
("inherit_commands", inherit_commands), - 1935
("inherit_plugins", inherit_plugins), - 1936
] { - 1937
if let Some(value) = value { - 1938
capabilities.insert(name.into(), toml::Value::Boolean(value)); - 1939
} - 1940
} - 1941
Ok(()) - 1942
}) - 1943
} - 1944
- 1945
/// Persist user-selected agent preferences without disturbing unrelated - 1946
/// project configuration. The write is atomic so every client sees either - 1947
/// the old or the new complete document, never a partial TOML file. - 1948
pub fn persist_project_preferences( - 1949
cwd: &Path, - 1950
provider: Option<&str>, - 1951
model: Option<&str>, - 1952
max_turns: Option<usize>, - 1953
permission_mode: Option<PermissionMode>, - 1954
approval_mode: Option<ApprovalMode>, - 1955
theme: Option<&str>, - 1956
) -> Result<(), ConfigError> { - 1957
persist_preferences_at( - 1958
project_path(cwd), - 1959
provider, - 1960
model, - 1961
max_turns, - 1962
permission_mode, - 1963
approval_mode, - 1964
theme, - 1965
) - 1966
} - 1967
- 1968
/// Persist user-level defaults. Project configurations inherit these values - 1969
/// through [`load_with_trust`] until they set their own scoped override. - 1970
pub fn persist_global_preferences( - 1971
provider: Option<&str>, - 1972
model: Option<&str>, - 1973
max_turns: Option<usize>, - 1974
permission_mode: Option<PermissionMode>, - 1975
approval_mode: Option<ApprovalMode>, - 1976
theme: Option<&str>, - 1977
) -> Result<(), ConfigError> { - 1978
let path = global_path().ok_or_else(|| ConfigError::Write { - 1979
path: PathBuf::from("<user-config>"), - 1980
source: std::io::Error::other("user home is unavailable"), - 1981
})?; - 1982
persist_preferences_at( - 1983
path, - 1984
provider, - 1985
model, - 1986
max_turns, - 1987
permission_mode, - 1988
approval_mode, - 1989
theme, - 1990
) - 1991
} - 1992
- 1993
/// Persist preferences to an explicitly chosen layer file. - 1994
/// - 1995
/// The project and global wrappers above cover the two named scopes; this - 1996
/// takes the path directly, for a caller that has already resolved which - 1997
/// layer it means (`vak config set-mode --scope`). - 1998
pub fn persist_preferences_to( - 1999
path: PathBuf, - 2000
provider: Option<&str>, - 2001
model: Option<&str>, - 2002
max_turns: Option<usize>, - 2003
permission_mode: Option<PermissionMode>, - 2004
approval_mode: Option<ApprovalMode>, - 2005
theme: Option<&str>, - 2006
) -> Result<(), ConfigError> { - 2007
persist_preferences_at( - 2008
path, - 2009
provider, - 2010
model, - 2011
max_turns, - 2012
permission_mode, - 2013
approval_mode, - 2014
theme, - 2015
) - 2016
} - 2017
- 2018
fn persist_preferences_at( - 2019
path: PathBuf, - 2020
provider: Option<&str>, - 2021
model: Option<&str>, - 2022
max_turns: Option<usize>, - 2023
permission_mode: Option<PermissionMode>, - 2024
approval_mode: Option<ApprovalMode>, - 2025
theme: Option<&str>, - 2026
) -> Result<(), ConfigError> { - 2027
update_config_file(&path, |document| { - 2028
if let Some(value) = provider { - 2029
document.insert("provider".into(), toml::Value::String(value.into())); - 2030
} - 2031
if let Some(value) = model { - 2032
document.insert("model".into(), toml::Value::String(value.into())); - 2033
} - 2034
if let Some(value) = max_turns { - 2035
document.insert("max_turns".into(), toml::Value::Integer(value as i64)); - 2036
} - 2037
if let Some(value) = permission_mode { - 2038
document.insert( - 2039
"permission_mode".into(), - 2040
toml::Value::String(value.as_str().into()), - 2041
); - 2042
} - 2043
if let Some(value) = approval_mode { - 2044
document.insert( - 2045
"approval_mode".into(), - 2046
toml::Value::String(value.as_str().into()), - 2047
); - 2048
} - 2049
if let Some(value) = theme { - 2050
child_table(document, "ui", &path)? - 2051
.insert("theme".into(), toml::Value::String(value.into())); - 2052
} - 2053
Ok(()) - 2054
}) - 2055
} - 2056
- 2057
/// Persist the evidence freshness policy in exactly one configuration layer. - 2058
pub fn persist_evidence_max_age(path: PathBuf, seconds: i64) -> Result<(), ConfigError> { - 2059
update_config_file(&path, |document| { - 2060
child_table(document, "intent", &path)?.insert( - 2061
"evidence_max_age_secs".into(), - 2062
toml::Value::Integer(seconds.max(0)), - 2063
); - 2064
Ok(()) - 2065
}) - 2066
} - 2067
- 2068
/// Changes the project's `[server.bus]` settings (the NATS URL and the name - 2069
/// of the workspace-secret variable), leaving the rest of the file as it - 2070
/// was: `None` leaves a key alone, `Some(None)` or an empty value removes - 2071
/// it, `Some(Some(value))` sets it. The bus's secrets never go here. - 2072
pub fn persist_bus_settings( - 2073
path: PathBuf, - 2074
nats_url: Option<Option<&str>>, - 2075
workspace_secret_env: Option<Option<&str>>, - 2076
) -> Result<(), ConfigError> { - 2077
update_config_file(&path, |document| { - 2078
let bus = child_table(child_table(document, "server", &path)?, "bus", &path)?; - 2079
for (key, change) in [ - 2080
("nats_url", nats_url), - 2081
("workspace_secret_env", workspace_secret_env), - 2082
] { - 2083
let Some(value) = change else { - 2084
continue; - 2085
}; - 2086
match value.map(str::trim).filter(|value| !value.is_empty()) { - 2087
Some(value) => { - 2088
bus.insert(key.into(), toml::Value::String(value.to_string())); - 2089
} - 2090
None => { - 2091
bus.remove(key); - 2092
} - 2093
} - 2094
} - 2095
Ok(()) - 2096
}) - 2097
} - 2098
- 2099
/// Persist `[memory]` toggles for the current project without disturbing - 2100
/// unrelated config (docs/design/23-memory.md). Mirrors - 2101
/// [`persist_project_preferences`]'s atomic-write shape exactly. - 2102
pub fn persist_project_memory_prefs( - 2103
cwd: &Path, - 2104
search_enabled: Option<bool>, - 2105
write_enabled: Option<bool>, - 2106
reflection: Option<bool>, - 2107
skill_proposals: Option<bool>, - 2108
) -> Result<(), ConfigError> { - 2109
persist_memory_prefs_at( - 2110
project_path(cwd), - 2111
search_enabled, - 2112
write_enabled, - 2113
reflection, - 2114
skill_proposals, - 2115
) - 2116
} - 2117
- 2118
/// Persist user-level `[memory]` defaults, inherited by project configs - 2119
/// through [`load_with_trust`] until they set their own scoped override. - 2120
pub fn persist_global_memory_prefs( - 2121
search_enabled: Option<bool>, - 2122
write_enabled: Option<bool>, - 2123
reflection: Option<bool>, - 2124
skill_proposals: Option<bool>, - 2125
) -> Result<(), ConfigError> { - 2126
let path = global_path().ok_or_else(|| ConfigError::Write { - 2127
path: PathBuf::from("<user-config>"), - 2128
source: std::io::Error::other("user home is unavailable"), - 2129
})?; - 2130
persist_memory_prefs_at( - 2131
path, - 2132
search_enabled, - 2133
write_enabled, - 2134
reflection, - 2135
skill_proposals, - 2136
) - 2137
} - 2138
- 2139
fn persist_memory_prefs_at( - 2140
path: PathBuf, - 2141
search_enabled: Option<bool>, - 2142
write_enabled: Option<bool>, - 2143
reflection: Option<bool>, - 2144
skill_proposals: Option<bool>, - 2145
) -> Result<(), ConfigError> { - 2146
update_config_file(&path, |document| { - 2147
if search_enabled.is_some() - 2148
|| write_enabled.is_some() - 2149
|| reflection.is_some() - 2150
|| skill_proposals.is_some() - 2151
{ - 2152
let memory = child_table(document, "memory", &path)?; - 2153
if let Some(value) = search_enabled { - 2154
memory.insert("search_enabled".into(), toml::Value::Boolean(value)); - 2155
} - 2156
if let Some(value) = write_enabled { - 2157
memory.insert("write_enabled".into(), toml::Value::Boolean(value)); - 2158
} - 2159
if let Some(value) = reflection { - 2160
memory.insert("reflection".into(), toml::Value::Boolean(value)); - 2161
} - 2162
if let Some(value) = skill_proposals { - 2163
memory.insert("skill_proposals".into(), toml::Value::Boolean(value)); - 2164
} - 2165
} - 2166
Ok(()) - 2167
}) - 2168
} - 2169
- 2170
/// Persist `[gateway] approvals` / `approver` without disturbing unrelated - 2171
/// config keys. - 2172
/// - 2173
/// This is the one setting that decides whether an `Ask` raised on an - 2174
/// unattended chat surface reaches a human at all: with `approvals = - 2175
/// "deny"` every gate is a foregone denial, `GatewayApprover::answerable()` - 2176
/// is false, and `vak_core::reach` correctly strips every gated capability - 2177
/// from the turn before the prompt is composed. It had no writer on any - 2178
/// surface — the remedy `reach` prints named an action nothing could - 2179
/// perform — so an operator's only route was editing this file by hand. - 2180
/// - 2181
/// `approver` is `Option<Option<String>>`: absent leaves it alone, `Some(None)` - 2182
/// clears it back to unset, `Some(Some(t))` pins the target. The - 2183
/// forward-requires-an-approver rule is NOT enforced here; it lives in - 2184
/// [`load_with_trust`], which is what every reader goes through, and - 2185
/// duplicating it would be a second contract that must agree forever. - 2186
/// Callers that want to reject the combination up front should check it - 2187
/// themselves and say so — writing a `forward` with no target simply - 2188
/// resolves back to `deny` with a warning, which is safe. - 2189
pub fn persist_gateway_approvals( - 2190
path: PathBuf, - 2191
approvals: Option<&str>, - 2192
approver: Option<Option<&str>>, - 2193
approval_timeout_secs: Option<u64>, - 2194
) -> Result<(), ConfigError> { - 2195
persist_gateway_approvals_at(path, approvals, approver, approval_timeout_secs) - 2196
} - 2197
- 2198
fn persist_gateway_approvals_at( - 2199
path: PathBuf, - 2200
approvals: Option<&str>, - 2201
approver: Option<Option<&str>>, - 2202
approval_timeout_secs: Option<u64>, - 2203
) -> Result<(), ConfigError> { - 2204
update_config_file(&path, |document| { - 2205
if approvals.is_some() || approver.is_some() || approval_timeout_secs.is_some() { - 2206
let gateway = child_table(document, "gateway", &path)?; - 2207
if let Some(value) = approvals { - 2208
gateway.insert("approvals".into(), toml::Value::String(value.into())); - 2209
} - 2210
match approver { - 2211
None => {} - 2212
Some(None) => { - 2213
gateway.remove("approver"); - 2214
} - 2215
Some(Some(target)) => { - 2216
gateway.insert("approver".into(), toml::Value::String(target.into())); - 2217
} - 2218
} - 2219
if let Some(value) = approval_timeout_secs { - 2220
gateway.insert( - 2221
"approval_timeout_secs".into(), - 2222
toml::Value::Integer(value as i64), - 2223
); - 2224
} - 2225
} - 2226
Ok(()) - 2227
}) - 2228
} - 2229
- 2230
/// Held by every write to a configuration file in this process, from the - 2231
/// read to the rename. One lock for every path rather than one per path: - 2232
/// the same file is reachable under more than one spelling (the default - 2233
/// workspace's project layer *is* the Shared layer), and a per-path lock - 2234
/// would first have to agree which spellings name one file. Configuration - 2235
/// writes are rare and small, so nothing waits on it for long. - 2236
static CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); - 2237
- 2238
fn lock_config_files() -> MutexGuard<'static, ()> { - 2239
CONFIG_FILE_LOCK - 2240
.lock() - 2241
.unwrap_or_else(std::sync::PoisonError::into_inner) - 2242
} - 2243
- 2244
/// The one way a configuration file is rewritten: under - 2245
/// [`CONFIG_FILE_LOCK`], read the document at `path` (a missing file is an - 2246
/// empty one), let `edit` change it, and atomically replace the file with - 2247
/// the result. - 2248
/// - 2249
/// Holding the lock from the read to the rename is what stops two settings - 2250
/// saved at the same moment from each rewriting the file from a read taken - 2251
/// before the other one landed. The document is edited as a raw TOML table, - 2252
/// so a key this version does not know is written back as it was read - 2253
/// (invariant 29). An edit that changes nothing writes nothing. `edit` must - 2254
/// not call another configuration writer: the lock is not reentrant. - 2255
fn update_config_file<T>( - 2256
path: &Path, - 2257
edit: impl FnOnce(&mut toml::Table) -> Result<T, ConfigError>, - 2258
) -> Result<T, ConfigError> { - 2259
let _guard = lock_config_files(); - 2260
let before = match std::fs::read_to_string(path) { - 2261
Ok(text) => toml::from_str::<toml::Table>(&text).map_err(|source| ConfigError::Parse { - 2262
path: path.to_path_buf(), - 2263
source, - 2264
})?, - 2265
Err(error) if error.kind() == std::io::ErrorKind::NotFound => toml::Table::new(), - 2266
Err(source) => { - 2267
return Err(ConfigError::Read { - 2268
path: path.to_path_buf(), - 2269
source, - 2270
}); - 2271
} - 2272
}; - 2273
let mut document = before.clone(); - 2274
let outcome = edit(&mut document)?; - 2275
if document != before { - 2276
let text = toml::to_string_pretty(&document).map_err(|error| ConfigError::Write { - 2277
path: path.to_path_buf(), - 2278
source: std::io::Error::other(error.to_string()), - 2279
})?; - 2280
replace_file(path, &text)?; - 2281
} - 2282
Ok(outcome) - 2283
} - 2284
- 2285
/// Write `contents` to a new sibling of `path` and rename it over `path`, so - 2286
/// a reader sees the whole old document or the whole new one. The temporary - 2287
/// name carries the process id and a per-process sequence number, and is - 2288
/// created exclusively, so no two writes ever share one. - 2289
fn replace_file(path: &Path, contents: &str) -> Result<(), ConfigError> { - 2290
static SEQUENCE: AtomicU64 = AtomicU64::new(0); - 2291
let write_error = |source| ConfigError::Write { - 2292
path: path.to_path_buf(), - 2293
source, - 2294
}; - 2295
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { - 2296
return Err(write_error(std::io::Error::other( - 2297
"config path has no parent directory", - 2298
))); - 2299
}; - 2300
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Write { - 2301
path: parent.to_path_buf(), - 2302
source, - 2303
})?; - 2304
let mut attempts = 0; - 2305
let (temp, mut file) = loop { - 2306
let temp = parent.join(format!( - 2307
".{}.{}.{}.tmp", - 2308
name.to_string_lossy(), - 2309
std::process::id(), - 2310
SEQUENCE.fetch_add(1, Ordering::Relaxed) - 2311
)); - 2312
match std::fs::OpenOptions::new() - 2313
.write(true) - 2314
.create_new(true) - 2315
.open(&temp) - 2316
{ - 2317
Ok(file) => break (temp, file), - 2318
// Only another process that had this pid can hold a fresh name; - 2319
// step past its file rather than write into it. - 2320
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempts < 8 => { - 2321
attempts += 1; - 2322
} - 2323
Err(source) => return Err(write_error(source)), - 2324
} - 2325
}; - 2326
let written = std::io::Write::write_all(&mut file, contents.as_bytes()); - 2327
drop(file); - 2328
written - 2329
.and_then(|()| std::fs::rename(&temp, path)) - 2330
.map_err(|source| { - 2331
let _ = std::fs::remove_file(&temp); - 2332
write_error(source) - 2333
}) - 2334
} - 2335
- 2336
/// The table under `key`, created empty when absent. A key that holds some - 2337
/// other kind of value is refused rather than overwritten. - 2338
fn child_table<'a>( - 2339
parent: &'a mut toml::Table, - 2340
key: &str, - 2341
path: &Path, - 2342
) -> Result<&'a mut toml::Table, ConfigError> { - 2343
parent - 2344
.entry(key) - 2345
.or_insert_with(|| toml::Value::Table(toml::Table::new())) - 2346
.as_table_mut() - 2347
.ok_or_else(|| ConfigError::Write { - 2348
path: path.to_path_buf(), - 2349
source: std::io::Error::other(format!("`{key}` config must be a TOML table")), - 2350
}) - 2351
} - 2352
- 2353
/// Persist the three permission rule lists (`allow` / `ask` / `deny`) as - 2354
/// the engine reads them, without disturbing unrelated config keys. - 2355
/// - 2356
/// Each list is `Option`: absent leaves that list alone, `Some(vec)` - 2357
/// replaces it wholesale (an empty vec clears it). - 2358
/// - 2359
/// Rule SYNTAX is not validated here on purpose: the grammar lives in - 2360
/// `vak_permission::Rule::parse`, which this crate sits below and must not - 2361
/// depend on. Callers parse every spec before calling — a second grammar - 2362
/// here would be two definitions of a rule that must agree forever. - 2363
pub fn persist_permission_rules( - 2364
path: PathBuf, - 2365
allow: Option<&[String]>, - 2366
ask: Option<&[String]>, - 2367
deny: Option<&[String]>, - 2368
) -> Result<(), ConfigError> { - 2369
update_config_file(&path, |document| { - 2370
for (key, list) in [("allow", allow), ("ask", ask), ("deny", deny)] { - 2371
let Some(list) = list else { continue }; - 2372
document.insert( - 2373
key.into(), - 2374
toml::Value::Array( - 2375
list.iter() - 2376
.map(|spec| toml::Value::String(spec.clone())) - 2377
.collect(), - 2378
), - 2379
); - 2380
} - 2381
Ok(()) - 2382
}) - 2383
} - 2384
- 2385
/// Persist the top-level `workers` toggle for the current project. - 2386
/// Mirrors [`persist_project_preferences`]'s atomic-write shape exactly. - 2387
pub fn persist_project_workers(cwd: &Path, enabled: bool) -> Result<(), ConfigError> { - 2388
persist_workers_at(project_path(cwd), enabled) - 2389
} - 2390
- 2391
/// Persist the user-level `workers` default, inherited by project - 2392
/// configs through [`load_with_trust`] until they set their own override. - 2393
pub fn persist_global_workers(enabled: bool) -> Result<(), ConfigError> { - 2394
let path = global_path().ok_or_else(|| ConfigError::Write { - 2395
path: PathBuf::from("<user-config>"), - 2396
source: std::io::Error::other("user home is unavailable"), - 2397
})?; - 2398
persist_workers_at(path, enabled) - 2399
} - 2400
- 2401
/// Persist the optional `[work]` policy fields without disturbing unrelated - 2402
/// config keys. The whole document is rewritten through the same atomic - 2403
/// rename boundary as the other authenticated preference endpoints. - 2404
pub fn persist_work_preferences( - 2405
path: PathBuf, - 2406
enabled: Option<bool>, - 2407
default_mode: Option<&str>, - 2408
max_items: Option<usize>, - 2409
max_revisions: Option<u32>, - 2410
max_parallel: Option<usize>, - 2411
confirmation: Option<&str>, - 2412
) -> Result<(), ConfigError> { - 2413
if [default_mode, confirmation] - 2414
.into_iter() - 2415
.flatten() - 2416
.any(|value| value.trim().is_empty()) - 2417
{ - 2418
return Err(ConfigError::Write { - 2419
path, - 2420
source: std::io::Error::other("work policy values cannot be empty"), - 2421
}); - 2422
} - 2423
update_config_file(&path, |document| { - 2424
let work = child_table(document, "work", &path)?; - 2425
if let Some(value) = enabled { - 2426
work.insert("enabled".into(), toml::Value::Boolean(value)); - 2427
} - 2428
if let Some(value) = default_mode { - 2429
work.insert("default_mode".into(), toml::Value::String(value.into())); - 2430
} - 2431
if let Some(value) = max_items { - 2432
work.insert("max_items".into(), toml::Value::Integer(value as i64)); - 2433
} - 2434
if let Some(value) = max_revisions { - 2435
work.insert("max_revisions".into(), toml::Value::Integer(value as i64)); - 2436
} - 2437
if let Some(value) = max_parallel { - 2438
work.insert("max_parallel".into(), toml::Value::Integer(value as i64)); - 2439
} - 2440
if let Some(value) = confirmation { - 2441
work.insert("confirmation".into(), toml::Value::String(value.into())); - 2442
} - 2443
Ok(()) - 2444
}) - 2445
} - 2446
- 2447
/// Persist `[plugins] network_allow` at the given layer path. - 2448
/// - 2449
/// `grant` is a three-state override: `Some(names)` grants egress to those - 2450
/// plugins, `Some(vec![])` removes the key entirely (so the layer reads as - 2451
/// deny-by-default and a narrower layer may inherit from a wider one), and - 2452
/// `None` leaves the file untouched. Grants are privileged and are also - 2453
/// demoted on read for untrusted project layers; callers must still refuse - 2454
/// a non-empty grant into an untrusted project rather than writing a value - 2455
/// the loader would silently discard. - 2456
pub fn persist_plugins_network_allow( - 2457
path: &Path, - 2458
grant: Option<Vec<String>>, - 2459
) -> Result<(), ConfigError> { - 2460
if let Some(names) = &grant - 2461
&& names.iter().any(|name| name.trim().is_empty()) - 2462
{ - 2463
return Err(ConfigError::Write { - 2464
path: path.to_path_buf(), - 2465
source: std::io::Error::other("plugin names cannot be empty"), - 2466
}); - 2467
} - 2468
update_config_file(path, |document| { - 2469
let plugins = child_table(document, "plugins", path)?; - 2470
match grant { - 2471
Some(names) if names.is_empty() => { - 2472
plugins.remove("network_allow"); - 2473
} - 2474
Some(names) => { - 2475
plugins.insert( - 2476
"network_allow".into(), - 2477
toml::Value::Array(names.into_iter().map(toml::Value::String).collect()), - 2478
); - 2479
} - 2480
None => {} - 2481
} - 2482
Ok(()) - 2483
}) - 2484
} - 2485
- 2486
fn persist_workers_at(path: PathBuf, enabled: bool) -> Result<(), ConfigError> { - 2487
update_config_file(&path, |document| { - 2488
document.insert("workers".into(), toml::Value::Boolean(enabled)); - 2489
// Drop the legacy alias so we don't leave two competing keys behind - 2490
// once this layer has been rewritten under the new name. - 2491
document.remove("subagents"); - 2492
Ok(()) - 2493
}) - 2494
} - 2495
- 2496
/// Persist `[finops]` budget caps for the current project. `None` leaves - 2497
/// that cap alone; `Some(None)` clears it (removes the key, so it reads - 2498
/// back as "no cap" rather than as an explicit zero); `Some(Some(v))` sets - 2499
/// it. Mirrors [`persist_project_preferences`]'s atomic-write shape. - 2500
pub fn persist_project_finops_caps( - 2501
cwd: &Path, - 2502
max_run_usd: Option<Option<f64>>, - 2503
max_day_usd: Option<Option<f64>>, - 2504
) -> Result<(), ConfigError> { - 2505
persist_finops_caps_at(project_path(cwd), max_run_usd, max_day_usd) - 2506
} - 2507
- 2508
/// A partial update to `[voice]`. `None` leaves a key untouched; for the - 2509
/// optional route keys `Some(None)` removes the key so it inherits again. - 2510
#[derive(Debug, Clone, Default)] - 2511
pub struct VoicePatch { - 2512
pub enabled: Option<bool>, - 2513
pub max_session_secs: Option<u64>, - 2514
pub max_concurrent: Option<usize>, - 2515
pub max_audio_bytes: Option<u64>, - 2516
pub provider: Option<Option<String>>, - 2517
pub transcription_model: Option<Option<String>>, - 2518
pub synthesis_model: Option<Option<String>>, - 2519
} - 2520
- 2521
impl VoicePatch { - 2522
pub fn is_empty(&self) -> bool { - 2523
self.enabled.is_none() - 2524
&& self.max_session_secs.is_none() - 2525
&& self.max_concurrent.is_none() - 2526
&& self.max_audio_bytes.is_none() - 2527
&& self.provider.is_none() - 2528
&& self.transcription_model.is_none() - 2529
&& self.synthesis_model.is_none() - 2530
} - 2531
} - 2532
- 2533
/// Atomically apply `patch` to the `[voice]` table of the config file at - 2534
/// `path`, preserving every key the patch does not name. - 2535
pub fn persist_voice_settings_at(path: PathBuf, patch: &VoicePatch) -> Result<(), ConfigError> { - 2536
update_config_file(&path, |document| { - 2537
let voice = child_table(document, "voice", &path)?; - 2538
if let Some(v) = patch.enabled { - 2539
voice.insert("enabled".into(), toml::Value::Boolean(v)); - 2540
} - 2541
for (key, value) in [ - 2542
("max_session_secs", patch.max_session_secs), - 2543
("max_concurrent", patch.max_concurrent.map(|v| v as u64)), - 2544
("max_audio_bytes", patch.max_audio_bytes), - 2545
] { - 2546
if let Some(v) = value { - 2547
voice.insert(key.into(), toml::Value::Integer(v as i64)); - 2548
} - 2549
} - 2550
for (key, value) in [ - 2551
("provider", &patch.provider), - 2552
("transcription_model", &patch.transcription_model), - 2553
("synthesis_model", &patch.synthesis_model), - 2554
] { - 2555
match value { - 2556
Some(Some(v)) => { - 2557
voice.insert(key.into(), toml::Value::String(v.clone())); - 2558
} - 2559
Some(None) => { - 2560
voice.remove(key); - 2561
} - 2562
None => {} - 2563
} - 2564
} - 2565
Ok(()) - 2566
}) - 2567
} - 2568
- 2569
/// Persist the user-level `[finops]` defaults, inherited by project - 2570
/// configs through [`load_with_trust`] until they set their own override. - 2571
pub fn persist_global_finops_caps( - 2572
max_run_usd: Option<Option<f64>>, - 2573
max_day_usd: Option<Option<f64>>, - 2574
) -> Result<(), ConfigError> { - 2575
let path = global_path().ok_or_else(|| ConfigError::Write { - 2576
path: PathBuf::from("<user-config>"), - 2577
source: std::io::Error::other("user home is unavailable"), - 2578
})?; - 2579
persist_finops_caps_at(path, max_run_usd, max_day_usd) - 2580
} - 2581
- 2582
fn persist_finops_caps_at( - 2583
path: PathBuf, - 2584
max_run_usd: Option<Option<f64>>, - 2585
max_day_usd: Option<Option<f64>>, - 2586
) -> Result<(), ConfigError> { - 2587
update_config_file(&path, |document| { - 2588
if max_run_usd.is_some() || max_day_usd.is_some() { - 2589
let finops = child_table(document, "finops", &path)?; - 2590
for (key, cap) in [("max_run_usd", max_run_usd), ("max_day_usd", max_day_usd)] { - 2591
match cap { - 2592
Some(Some(value)) => { - 2593
finops.insert(key.into(), toml::Value::Float(value)); - 2594
} - 2595
Some(None) => { - 2596
finops.remove(key); - 2597
} - 2598
None => {} - 2599
} - 2600
} - 2601
} - 2602
Ok(()) - 2603
}) - 2604
} - 2605
- 2606
pub fn load(cwd: &Path) -> Result<Config, ConfigError> { - 2607
load_with_trust(cwd, true) - 2608
} - 2609
- 2610
/// Keys a PROJECT-level config may not set when its workspace has not been - 2611
/// marked trusted: they grant execution or redirect credentials. - 2612
const PRIVILEGED_KEYS_NOTICE: &str = "permission_mode, approval_mode, allow, hooks, anthropic_base_url, mcp.servers, gateway, sandbox, server, update, capabilities, intent.autonomy, intent.escalate=cloud, intent.enabled=false, intent.posture=false, plugins.network_allow, server.bus, feeds"; - 2613
- 2614
pub fn load_with_trust(cwd: &Path, trust_project: bool) -> Result<Config, ConfigError> { - 2615
let mut warnings = Vec::new(); - 2616
let mut layers: Vec<FileConfig> = vec![FileConfig::default()]; - 2617
- 2618
if let Some(gp) = global_path().filter(|gp| gp.is_file()) { - 2619
let (fc, w) = parse_file(&gp)?; - 2620
warnings.extend(w); - 2621
layers.push(fc); - 2622
} - 2623
let pp = project_path(cwd); - 2624
let is_global_workspace = global_path().is_some_and(|global| global == pp); - 2625
if pp.is_file() && !is_global_workspace { - 2626
let (mut fc, w) = parse_file(&pp)?; - 2627
warnings.extend(w); - 2628
if !trust_project { - 2629
// A repository must not be able to configure itself into - 2630
// execution power on first run. Restrictive keys (deny/ask) - 2631
// still apply. - 2632
if fc.permission_mode.is_some() { - 2633
fc.permission_mode = None; - 2634
} - 2635
if fc.approval_mode.is_some() { - 2636
fc.approval_mode = None; - 2637
} - 2638
if fc.anthropic_base_url.is_some() { - 2639
fc.anthropic_base_url = None; - 2640
} - 2641
fc.allow.clear(); - 2642
fc.hooks.clear(); - 2643
fc.mcp.servers.clear(); - 2644
if fc.gateway.enabled.is_some() || !fc.gateway.outbound.webhooks.is_empty() { - 2645
// Outbound webhook URLs are exfil targets just like base-url - 2646
// redirection: the whole section is privileged. - 2647
fc.gateway = GatewaySettings::default(); - 2648
} - 2649
// Image choice is supply-chain power; keep it with the user. - 2650
if fc.sandbox.backend.is_some() || fc.sandbox.image.is_some() { - 2651
fc.sandbox = SandboxSettings::default(); - 2652
} - 2653
// The release feed decides which binary replaces this one, and - 2654
// its artifact hashes come from the feed itself — an attacker who - 2655
// picks the URL picks the checksum too. Same class of power as - 2656
// anthropic_base_url, and it was not stripped here. - 2657
if fc.update.url.is_some() { - 2658
fc.update.url = None; - 2659
} - 2660
// `inherit_* = false` clears the corresponding global layer in - 2661
// `merge_into`, so an untrusted project could switch off the - 2662
// user's own hooks and MCP servers — disabling a protection is - 2663
// as privileged as adding a capability. - 2664
fc.capabilities = CapabilityInheritanceSettings::default(); - 2665
// `delegated` and `autonomous` suppress approval gates, and a - 2666
// cloud classification tier spends the user's credentials before - 2667
// the run they asked for. Both are execution power; a cloned - 2668
// repository must not grant them to itself. The rest of [intent] - 2669
// only ever narrows, so it survives untrusted. - 2670
if fc.intent.autonomy.is_some() { - 2671
fc.intent.autonomy = None; - 2672
} - 2673
if fc.intent.escalate.as_deref() == Some("cloud") { - 2674
fc.intent.escalate = None; - 2675
} - 2676
// Switching the kernel or its posture off removes the approval - 2677
// floor it raises — "force push to production" asks even under - 2678
// auto-approve only while both are on — so turning either off is - 2679
// disabling a protection, which is as privileged as granting. - 2680
if fc.intent.enabled == Some(false) { - 2681
fc.intent.enabled = None; - 2682
} - 2683
if fc.intent.posture == Some(false) { - 2684
fc.intent.posture = None; - 2685
} - 2686
// Network exposure is not a project's decision to make. `bind` - 2687
// chooses which interface answers, `trusted_hosts` relaxes the - 2688
// DNS-rebinding defence, and `web.terminal` opens a shell to - 2689
// whoever can reach the port — a cloned repository that could - 2690
// set these would be handing itself the machine. - 2691
fc.server = ServerSettings::default(); - 2692
fc.plugins.network_allow = None; - 2693
fc.plugins.allow = None; - 2694
// The feed pipeline is an unattended surface: `feeds.enabled` - 2695
// makes the scheduler fetch and run the pipeline every tick, and - 2696
// the pipeline is executable code with network access. A cloned - 2697
// repository that could switch it on would be handing itself an - 2698
// unattended runner (invariant 15). The whole section is - 2699
// privileged, like [server] and [gateway]. - 2700
fc.feeds = FeedSettings::default(); - 2701
warnings.push(format!( - 2702
"project .vak/config.toml is not trusted for this workspace; \ - 2703
ignored privileged keys ({PRIVILEGED_KEYS_NOTICE}). \ - 2704
Re-run and confirm the workspace prompt, or pass --trust, to apply them." - 2705
)); - 2706
} - 2707
layers.push(fc); - 2708
} - 2709
- 2710
let mut merged = FileConfig::default(); - 2711
for layer in layers { - 2712
merge_into(&mut merged, layer); - 2713
} - 2714
- 2715
if let Ok(m) = std::env::var("VAK_MODEL") { - 2716
merged.model = Some(m); - 2717
} - 2718
if let Ok(p) = std::env::var("VAK_PROVIDER") { - 2719
merged.provider = Some(p); - 2720
} - 2721
- 2722
let mut cfg = Config { - 2723
warnings, - 2724
..Default::default() - 2725
}; - 2726
if let Some(provider) = merged.provider { - 2727
cfg.provider = provider; - 2728
} - 2729
if let Some(model) = merged.model { - 2730
cfg.model = model; - 2731
} - 2732
if let Some(mt) = merged.max_tokens { - 2733
cfg.max_tokens = mt; - 2734
} - 2735
if let Some(turns) = merged.max_turns { - 2736
cfg.max_turns = turns; - 2737
} - 2738
if let Some(mode) = merged.permission_mode {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.