- 1
//! Reconciling *configured* capability with *reachable* capability. - 2
//! - 3
//! vak decides what a turn may do across a lot of independent layers: - 4
//! permission mode (itself capped bot → chat → workspace), allow/ask/deny - 5
//! rules, channel overlays, the frozen session contract, MCP server and - 6
//! tool globs, the sandbox, approval mode, and finally whichever - 7
//! `Approver` the hosting surface installed. Each layer is individually - 8
//! correct and individually tested. Nothing composed them. - 9
//! - 10
//! The gap that opened is not a bug in any one layer — it is that the - 11
//! system prompt advertised the *configured* set while dispatch enforced - 12
//! the *composed* set, and the two were never compared. A Telegram turn - 13
//! would be told "Configured MCP servers: search", spend three tool calls - 14
//! discovering that every one of them is refused by an approver that was - 15
//! never going to say yes, and then tell the user it had no way to search - 16
//! the web. Everything worked exactly as designed and the outcome was a - 17
//! lie. - 18
//! - 19
//! This module is the comparison. It runs the real [`PermissionEngine`] - 20
//! against a probe of each advertised capability, folds in the approval - 21
//! mode and whether this surface's approver can answer anything at all, - 22
//! and returns one [`Standing`] per capability. Every surface reads it: - 23
//! the prompt advertises only what is reachable and names what is not, the - 24
//! tool registry drops what cannot be called, `doctor` reports the - 25
//! mismatch, and the audit log records it. - 26
//! - 27
//! It grants nothing. A `Blocked` standing removes a capability from the - 28
//! turn; it never adds one, and it never resolves a gate (AGENTS.md - 29
//! invariant 15 — unattended surfaces fail closed, and they still do). - 30
//! The change is that failing closed is now *stated up front* instead of - 31
//! discovered one denied tool call at a time. - 32
- 33
use std::path::Path; - 34
- 35
use serde_json::json; - 36
- 37
use vak_permission::{Decision, Mode, PermissionEngine}; - 38
- 39
/// What a capability's standing actually is once every layer has spoken. - 40
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 41
pub enum Reach { - 42
/// Callable. No gate, or a gate this turn's approval mode resolves. - 43
Open, - 44
/// Callable, but each use raises a gate somebody can answer. - 45
Gated, - 46
/// Configured, and not callable on this turn. Either a rule denies it - 47
/// outright, or it gates on an approval nobody here can answer. - 48
Blocked, - 49
} - 50
- 51
impl Reach { - 52
pub fn is_blocked(self) -> bool { - 53
self == Reach::Blocked - 54
} - 55
} - 56
- 57
/// One capability's standing, with enough detail to explain itself to a - 58
/// model, an operator, and an audit log without any of them re-deriving it. - 59
#[derive(Debug, Clone)] - 60
pub struct Standing { - 61
/// The capability this standing is about. - 62
pub id: crate::capability::CapabilityId, - 63
/// The tool the model would actually call (`mcp`, `webfetch`, `browse`). - 64
pub tool: String, - 65
/// How the capability is named to a reader: `mcp server \`search\``. - 66
pub label: String, - 67
pub reach: Reach, - 68
/// The deciding layer's own words. - 69
pub reason: String, - 70
/// What an operator would change to make it reachable. Empty when it - 71
/// already is. - 72
pub remedy: String, - 73
} - 74
- 75
/// Everything the composed policy needs in order to answer "can this turn - 76
/// actually use that?". Gathered by the caller because every field is - 77
/// per-turn state: the engine and mode come from the channel-capped - 78
/// resolution, the approver from the hosting surface. - 79
pub struct Probe<'a> { - 80
pub engine: &'a PermissionEngine, - 81
pub mode: Mode, - 82
pub approval_mode: vak_agent::ApprovalMode, - 83
pub sandboxed: bool, - 84
pub cwd: &'a Path, - 85
/// `Approver::answerable()` for this turn's approver. `false` for an - 86
/// unattended surface; `None` when no approver is installed at all, - 87
/// which is also unanswerable. - 88
pub approver_answerable: bool, - 89
/// Configured MCP server names, after channel filtering. - 90
pub mcp_servers: &'a [String], - 91
/// Registered network tools, after channel filtering. - 92
pub network_tools: &'a [String], - 93
/// Discovered skills, after channel filtering. - 94
pub skills: &'a [String], - 95
} - 96
- 97
/// Compute one standing per advertised capability. - 98
pub fn standings(probe: &Probe<'_>) -> Vec<Standing> { - 99
let mut out = Vec::new(); - 100
for server in probe.mcp_servers { - 101
// No tool name yet: `arg_candidates` yields nothing for a call - 102
// without one, so patterned rules cannot match and the answer is - 103
// the blanket floor. `resolve` degrades to `Gated` when a - 104
// patterned rule exists, precisely because this probe cannot see it. - 105
let args = json!({ "action": "call", "server": server }); - 106
let (reach, reason) = resolve(probe, "mcp", &args); - 107
// The engine describes a call with no tool name as - 108
// `mcp call <server>/<missing tool>` — right for a real call that - 109
// forgot the argument, wrong here, where the absence is the probe's - 110
// own doing. The standing is about the server, so drop the - 111
// placeholder rather than reporting a malformed call to a reader. - 112
let reason = reason.replace("/<missing tool>", ""); - 113
out.push(Standing { - 114
id: crate::capability::CapabilityId::new( - 115
vak_session::types::CapabilityKind::McpServer, - 116
server, - 117
), - 118
tool: "mcp".into(), - 119
label: format!("mcp server `{server}`"), - 120
remedy: remedy_for(probe, "mcp", reach), - 121
reach, - 122
reason, - 123
}); - 124
} - 125
for tool in probe.network_tools { - 126
let (reach, reason) = resolve(probe, tool, &json!({})); - 127
out.push(Standing { - 128
id: crate::capability::CapabilityId::new( - 129
vak_session::types::CapabilityKind::Tool, - 130
tool, - 131
), - 132
tool: tool.clone(), - 133
label: format!("`{tool}`"), - 134
remedy: remedy_for(probe, tool, reach), - 135
reach, - 136
reason, - 137
}); - 138
} - 139
for skill in probe.skills { - 140
let args = json!({ "name": skill }); - 141
let (reach, reason) = resolve(probe, "skill", &args); - 142
out.push(Standing { - 143
id: crate::capability::CapabilityId::new( - 144
vak_session::types::CapabilityKind::Skill, - 145
skill, - 146
), - 147
tool: "skill".into(), - 148
label: format!("skill `{skill}`"), - 149
remedy: remedy_for(probe, "skill", reach), - 150
reach, - 151
reason, - 152
}); - 153
} - 154
out - 155
} - 156
- 157
fn resolve(probe: &Probe<'_>, tool: &str, args: &serde_json::Value) -> (Reach, String) { - 158
match probe.engine.evaluate(tool, args, probe.mode, probe.cwd) { - 159
Decision::Allow => (Reach::Open, String::new()), - 160
Decision::Deny { reason } => (Reach::Blocked, reason), - 161
Decision::Ask { reason, source } => { - 162
if vak_agent::auto_approve( - 163
probe.approval_mode, - 164
source, - 165
tool, - 166
args, - 167
probe.mode, - 168
probe.sandboxed, - 169
probe.cwd, - 170
) { - 171
return (Reach::Open, String::new()); - 172
} - 173
if probe.approver_answerable { - 174
return (Reach::Gated, reason); - 175
} - 176
// Uncertainty degrades to `Gated`, never to `Blocked`. A - 177
// patterned rule this probe could not evaluate might allow the - 178
// real call, and hiding a capability that would have worked is - 179
// worse than advertising one that gates. - 180
if probe.engine.has_patterned_rule(tool) { - 181
return (Reach::Gated, reason); - 182
} - 183
( - 184
Reach::Blocked, - 185
format!("{reason}, and this surface has no approver to answer it"), - 186
) - 187
} - 188
} - 189
} - 190
- 191
fn remedy_for(probe: &Probe<'_>, tool: &str, reach: Reach) -> String { - 192
if !reach.is_blocked() { - 193
return String::new(); - 194
} - 195
let mut options = vec![format!( - 196
"allow `{tool}` outright with `allow = [\"{tool}\"]` in .vak/config.toml" - 197
)]; - 198
if !probe.approver_answerable { - 199
options.push( - 200
"give the surface an approver that can answer — for a chat gateway, \ - 201
`[gateway] approvals = \"forward\"` with `approver = \"<surface>:<chat>\"`" - 202
.to_string(), - 203
); - 204
} - 205
options.join("; or ") - 206
} - 207
- 208
/// Tools with no reachable use left on this turn, so the registry can drop - 209
/// them. A tool stays if *any* of its standings is reachable: one blocked - 210
/// MCP server must not take the broker down with it. - 211
pub fn fully_blocked_tools(standings: &[Standing]) -> Vec<String> { - 212
let mut blocked: Vec<String> = Vec::new(); - 213
for standing in standings { - 214
if blocked.contains(&standing.tool) { - 215
continue; - 216
} - 217
let all_blocked = standings - 218
.iter() - 219
.filter(|other| other.tool == standing.tool) - 220
.all(|other| other.reach.is_blocked()); - 221
if all_blocked { - 222
blocked.push(standing.tool.clone()); - 223
} - 224
} - 225
blocked - 226
} - 227
- 228
/// MCP servers that cannot be called, so the prompt's MCP catalogue can - 229
/// leave them out rather than advertising them as usable. - 230
pub fn blocked_mcp_servers(standings: &[Standing]) -> Vec<String> { - 231
standings - 232
.iter() - 233
.filter(|standing| { - 234
standing.id.kind == vak_session::types::CapabilityKind::McpServer - 235
&& standing.reach.is_blocked() - 236
}) - 237
.map(|standing| standing.id.name.clone()) - 238
.collect() - 239
} - 240
- 241
/// Skills that cannot be loaded, so the prompt's skills section and - 242
/// admitted capabilities can exclude them rather than advertising them as usable. - 243
pub fn blocked_skills(standings: &[Standing]) -> Vec<String> { - 244
standings - 245
.iter() - 246
.filter(|standing| { - 247
standing.id.kind == vak_session::types::CapabilityKind::Skill - 248
&& standing.reach.is_blocked() - 249
}) - 250
.map(|standing| standing.id.name.clone()) - 251
.collect() - 252
} - 253
- 254
/// The model-visible section. Stating this is the point: a model that - 255
/// knows a capability is configured but unreachable can say so, and say - 256
/// what would fix it, instead of spending the turn discovering it one - 257
/// denied call at a time and then reporting that it has no such tool. - 258
pub fn prompt_section(standings: &[Standing]) -> String { - 259
let blocked: Vec<&Standing> = standings - 260
.iter() - 261
.filter(|standing| standing.reach.is_blocked()) - 262
.collect(); - 263
if blocked.is_empty() { - 264
return String::new(); - 265
} - 266
let mut section = String::from( - 267
"\nConfigured but NOT usable on this turn. These are not in your tool \ - 268
schemas and calling them will fail. If the request needs one, say so \ - 269
plainly, name the capability, and give the operator the fix — do not \ - 270
substitute a different tool and do not answer as though you had the \ - 271
data:\n", - 272
); - 273
for standing in blocked { - 274
section.push_str(&format!("- {}: {}.", standing.label, standing.reason)); - 275
if !standing.remedy.is_empty() { - 276
section.push_str(&format!(" Fix: {}.", standing.remedy)); - 277
} - 278
section.push('\n'); - 279
} - 280
section - 281
} - 282
- 283
/// One audit line per blocked capability, for the security log. - 284
pub fn audit_details(standings: &[Standing]) -> Vec<String> { - 285
standings - 286
.iter() - 287
.filter(|standing| standing.reach.is_blocked()) - 288
.map(|standing| format!("capability={} reason={}", standing.label, standing.reason)) - 289
.collect() - 290
} - 291
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.