- 1
//! Wiring the intent kernel into the runtime. - 2
//! - 3
//! `vak-intent` is a pure decision layer that knows nothing about sessions, - 4
//! providers, or the permission engine. This module is the seam: it gathers - 5
//! the facts a reading needs, runs the cascade, and projects the resulting - 6
//! engagement onto the runtime knobs it governs. - 7
//! - 8
//! # The projection contract - 9
//! - 10
//! Every function here takes a baseline and returns something no wider. - 11
//! Nothing in this module grants: an approval ceiling takes the stricter of - 12
//! itself and the configured mode, a permission ceiling caps the mode, and a - 13
//! spend ceiling takes the smaller. What a reading predicts decides only - 14
//! which admitted tools are loaded, never what is possible: the route - 15
//! ladder, the turn budget and delegation are the operator's, whatever the - 16
//! request looked like. `debug_assert`s state the property at each site and - 17
//! `tests/intent_projection.rs` proves it. - 18
//! - 19
//! Getting a reading wrong must therefore be able to make vak *more* - 20
//! cautious, and never less. - 21
- 22
use std::collections::BTreeSet; - 23
use std::path::{Path, PathBuf}; - 24
- 25
use vak_intent::{ - 26
ApprovalCeiling, Authority, Autonomy, Declared, Engagement, HistoryFacts, Intent, Limits, - 27
PermissionCeiling, Request, Resolution, ResolverConfig, Surface as IntentSurface, - 28
WorkspaceFacts, - 29
}; - 30
- 31
use crate::Surface; - 32
- 33
/// Map the runtime's surface onto the kernel's. - 34
/// - 35
/// `Unknown` reads as `Server` rather than `Cli`: a caller that did not say - 36
/// where it was is not evidence that a human is sitting there, and assuming - 37
/// one would let an unattended embedder raise gates nobody answers. - 38
pub fn intent_surface(surface: &Surface) -> IntentSurface { - 39
match surface { - 40
Surface::Cli | Surface::Terminal => IntentSurface::Cli, - 41
// The web client IS the desktop client, in a tab: the same panes, - 42
// the same approval cards, the same person watching. Reading it as - 43
// `Server` would treat an attended session as an unattended - 44
// embedder and stop raising gates that someone is right there to - 45
// answer (docs/design/48-web-client.md). - 46
Surface::Desktop | Surface::Web => IntentSurface::Desktop, - 47
Surface::Server | Surface::Unknown => IntentSurface::Server, - 48
Surface::Chat { .. } => IntentSurface::Chat, - 49
Surface::Background => IntentSurface::Cron, - 50
Surface::Worker => IntentSurface::Worker, - 51
} - 52
} - 53
- 54
/// Configured autonomy, parsed once with a safe fallback. - 55
pub fn configured_autonomy(config: &vak_config::Config) -> Autonomy { - 56
Autonomy::parse(&config.intent.autonomy).unwrap_or(Autonomy::Assisted) - 57
} - 58
- 59
/// Build the resolver configuration from the workspace's settings. - 60
pub fn resolver_config(config: &vak_config::Config) -> ResolverConfig { - 61
ResolverConfig { - 62
enabled: config.intent.enabled, - 63
accept_confidence: config.intent.accept_confidence, - 64
provisional_confidence: config.intent.provisional_confidence, - 65
slice_capabilities: config.intent.slice_capabilities, - 66
allow_escalation: config.intent.escalate != "none", - 67
} - 68
} - 69
- 70
/// Facts about the workspace, gathered cheaply. - 71
/// - 72
/// Deliberately shallow: this runs before every turn, so it may not walk the - 73
/// tree or shell out. `.git` presence and index mtime are enough to separate - 74
/// "a repository with work in progress" from "an empty directory", which is - 75
/// all the reading needs. - 76
pub fn workspace_facts(cwd: &std::path::Path) -> WorkspaceFacts { - 77
let git_dir = cwd.join(".git"); - 78
let is_repo = git_dir.exists(); - 79
// A modified index is a cheap, dependency-free proxy for "there is - 80
// uncommitted work here": it is touched by `git add`/`git rm` and by most - 81
// porcelain that changes the tree. It can miss purely-unstaged edits, so - 82
// it is used only to *raise* caution, never to lower it. - 83
let has_uncommitted_changes = is_repo - 84
&& std::fs::metadata(git_dir.join("index")) - 85
.and_then(|meta| meta.modified()) - 86
.ok() - 87
.zip( - 88
std::fs::metadata(git_dir.join("HEAD")) - 89
.and_then(|meta| meta.modified()) - 90
.ok(), - 91
) - 92
.is_some_and(|(index, head)| index > head); - 93
WorkspaceFacts { - 94
is_repo, - 95
has_uncommitted_changes, - 96
} - 97
} - 98
- 99
/// What the session so far says about the next request: how many messages - 100
/// it holds, what the last turn was read as, and which threads are open. - 101
/// - 102
/// One function for the turn and for every preview of it (`vak intent - 103
/// explain`, `GET /intent/explain`), so a preview reads a request exactly as - 104
/// the turn would. - 105
pub fn history_facts(session: &vak_session::SessionLog) -> HistoryFacts { - 106
let chain = session.chain_to_root(); - 107
let previous_act = chain.iter().rev().find_map(|entry| match &entry.payload { - 108
vak_session::EntryPayload::Intent(record) => Some(record.reading.act), - 109
_ => None, - 110
}); - 111
let turn_index = chain - 112
.iter() - 113
.filter(|entry| matches!(entry.payload, vak_session::EntryPayload::Message(_))) - 114
.count(); - 115
HistoryFacts { - 116
previous_act, - 117
turn_index, - 118
open_threads: open_threads(session), - 119
} - 120
} - 121
- 122
/// Resolve one turn's intent. - 123
/// - 124
/// `turn_id` is the id the host minted for this turn (a UUIDv7); strand and - 125
/// thread ids derive from it, so a thread — and the commitment keyed by it — - 126
/// is unique across turns and sessions. A preview passes `""` and gets - 127
/// positional ids, which it never persists. - 128
/// - 129
/// Returns the resolution rather than an `Intent` so the caller can decide - 130
/// whether to spend a classification dispatch on an - 131
/// [`Resolution::Escalate`]. The partial inside it is always safe to use. - 132
#[allow(clippy::too_many_arguments)] - 133
pub fn resolve_turn( - 134
text: &str, - 135
turn_id: &str, - 136
surface: &Surface, - 137
attachments: &[vak_intent::Attachment], - 138
workspace: WorkspaceFacts, - 139
history: HistoryFacts, - 140
declared: &Declared, - 141
authority: &Authority, - 142
config: &ResolverConfig, - 143
) -> Resolution { - 144
// An explicit `/goal fix` or `/goal replace` is the only way a strand - 145
// becomes a correction or a replacement of earlier work. - 146
let (text, lineage_hint) = match vak_intent::parse_command(text) { - 147
Some(vak_intent::Command::GoalFix { text }) => { - 148
(text, Some(vak_intent::LineageHint::Corrects)) - 149
} - 150
Some(vak_intent::Command::GoalReplace { text }) => { - 151
(text, Some(vak_intent::LineageHint::Replaces)) - 152
} - 153
_ => (text.to_string(), None), - 154
}; - 155
let request = Request { - 156
text: &text, - 157
turn_id, - 158
surface: intent_surface(surface), - 159
attachments, - 160
workspace, - 161
history, - 162
attendance_override: Some(authority.attendance), - 163
lineage_hint, - 164
}; - 165
vak_intent::resolve(&request, declared, authority, config) - 166
} - 167
- 168
/// The threads still open in a session, for strand lineage. - 169
/// - 170
/// A thread is open while its most recent strand is not `Replaces`d and no - 171
/// later turn closed it; the last `MAX_OPEN_THREADS` distinct threads are - 172
/// kept, newest first, so a long session does not link every request to - 173
/// something said an hour ago. - 174
pub fn open_threads(session: &vak_session::SessionLog) -> Vec<vak_intent::ThreadFact> { - 175
const MAX_OPEN_THREADS: usize = 12; - 176
let mut seen: BTreeSet<String> = BTreeSet::new(); - 177
let mut replaced: BTreeSet<String> = BTreeSet::new(); - 178
let mut out = Vec::new(); - 179
for entry in session.chain_to_root().into_iter().rev() { - 180
let vak_session::EntryPayload::Intent(record) = &entry.payload else { - 181
continue; - 182
}; - 183
for strand in record.strands.iter().rev() { - 184
if let vak_intent::Lineage::Replaces { thread_id } = &strand.lineage { - 185
replaced.insert(thread_id.clone()); - 186
} - 187
if replaced.contains(&strand.thread_id) || !seen.insert(strand.thread_id.clone()) { - 188
continue; - 189
} - 190
out.push(vak_intent::ThreadFact { - 191
thread_id: strand.thread_id.clone(), - 192
act: strand.reading.act, - 193
domains: strand.reading.domains.clone(), - 194
keywords: vak_intent::strand::keywords(&strand.text), - 195
}); - 196
if out.len() >= MAX_OPEN_THREADS { - 197
break; - 198
} - 199
} - 200
if out.len() >= MAX_OPEN_THREADS { - 201
break; - 202
} - 203
} - 204
// Oldest first, so "the most recent open thread" is `last()`. - 205
out.reverse(); - 206
out - 207
} - 208
- 209
// --------------------------------------------------------- projections --- - 210
- 211
/// The approval mode this turn should run under. - 212
/// - 213
/// Takes the **stricter** of the configured mode and the engagement's ceiling. - 214
/// Intent can therefore force a gate the configuration would have skipped, and - 215
/// can never skip one the configuration wanted. - 216
pub fn approval_mode( - 217
configured: vak_config::ApprovalMode, - 218
ceiling: ApprovalCeiling, - 219
posture_enabled: bool, - 220
) -> vak_config::ApprovalMode { - 221
if !posture_enabled { - 222
return configured; - 223
} - 224
let configured_rank = match configured { - 225
vak_config::ApprovalMode::Ask => 0, - 226
vak_config::ApprovalMode::ApproveSafe => 1, - 227
vak_config::ApprovalMode::AutoApprove => 2, - 228
}; - 229
let effective = configured_rank.min(ceiling.rank()); - 230
let result = match effective { - 231
0 => vak_config::ApprovalMode::Ask, - 232
1 => vak_config::ApprovalMode::ApproveSafe, - 233
_ => vak_config::ApprovalMode::AutoApprove, - 234
}; - 235
debug_assert!( - 236
approval_rank(result) <= configured_rank, - 237
"intent loosened the configured approval mode" - 238
); - 239
result - 240
} - 241
- 242
fn approval_rank(mode: vak_config::ApprovalMode) -> u8 { - 243
match mode { - 244
vak_config::ApprovalMode::Ask => 0, - 245
vak_config::ApprovalMode::ApproveSafe => 1, - 246
vak_config::ApprovalMode::AutoApprove => 2, - 247
} - 248
} - 249
- 250
/// The permission mode this turn should run under. - 251
/// - 252
/// Uses the existing `PermissionMode::capped_by`, which is the one place in - 253
/// the codebase that reconciles a requested grant against a ceiling. An - 254
/// envelope narrows through the same door as a gateway channel override. - 255
pub fn permission_mode( - 256
configured: vak_config::PermissionMode, - 257
ceiling: PermissionCeiling, - 258
) -> vak_config::PermissionMode { - 259
let ceiling = match ceiling { - 260
PermissionCeiling::ReadOnly => vak_config::PermissionMode::ReadOnly, - 261
PermissionCeiling::WorkspaceWrite => vak_config::PermissionMode::WorkspaceWrite, - 262
PermissionCeiling::FullAccess => vak_config::PermissionMode::FullAccess, - 263
}; - 264
let result = configured.capped_by(ceiling); - 265
debug_assert!( - 266
result.rank() <= configured.rank(), - 267
"intent widened the permission mode" - 268
); - 269
result - 270
} - 271
- 272
/// The spend ceiling for this run: the smaller of the configured cap and the - 273
/// engagement's. - 274
pub fn spend_ceiling(configured: Option<f64>, engagement: Option<f64>) -> Option<f64> { - 275
match (configured, engagement) { - 276
(None, other) => other, - 277
(this, None) => this, - 278
(Some(a), Some(b)) => Some(a.min(b)), - 279
} - 280
} - 281
- 282
/// Demand facts for the route ladder's objective selection. - 283
/// - 284
/// This is the call that turns the existing router on. `plan_route_ladder` has - 285
/// always passed zeros here, so every session scored identical demand and the - 286
/// objective was effectively constant. - 287
pub fn demand_input( - 288
engagement: &Engagement, - 289
estimated_input_tokens: u64, - 290
output_budget_tokens: u64, - 291
tool_count: usize, - 292
) -> vak_llm::DemandInput { - 293
vak_llm::DemandInput { - 294
estimated_input_tokens, - 295
output_budget_tokens, - 296
tool_count, - 297
structured_output: engagement.posture.demand.structured_output, - 298
reasoning_required: engagement.posture.demand.reasoning_required, - 299
evidence_required: engagement.posture.demand.evidence_required, - 300
} - 301
} - 302
- 303
/// Whether a route leg can serve this turn's modalities. - 304
/// - 305
/// Model catalogues are discovered, never hardcoded (`AGENTS.md` invariant 9), - 306
/// and multimodal support is a property of the model rather than of our source - 307
/// tree. So capability comes from operator-declared hints — the same mechanism - 308
/// `route.quality_hints` already uses — and a turn with no declared hints - 309
/// treats every leg as capable rather than inventing a restriction. - 310
pub fn leg_supports_modalities( - 311
model: &str, - 312
required: &BTreeSet<vak_intent::Modality>, - 313
hints: &[String], - 314
) -> bool { - 315
if required.is_empty() || hints.is_empty() { - 316
return true; - 317
} - 318
let model = model.to_ascii_lowercase(); - 319
hints - 320
.iter() - 321
.any(|hint| model.contains(&hint.to_ascii_lowercase())) - 322
} - 323
- 324
/// An approver that parks a gate nobody here can answer. - 325
/// - 326
/// The `Defer` human-in-the-loop mode (docs/design/47-commitment-kernel.md): - 327
/// when the surface cannot answer and the work is durable enough to own a - 328
/// commitment, an `Ask` becomes an inbox entry and a `Suspended { Human }` - 329
/// event on the commitment, and the turn still fails closed — nothing - 330
/// happens without the answer, but the work survives to be resumed. On a - 331
/// surface that *can* answer, this is transparent. - 332
pub struct DeferringApprover { - 333
inner: Option<std::sync::Arc<dyn vak_agent::Approver>>, - 334
shared_home: std::path::PathBuf, - 335
sessions_home: std::path::PathBuf, - 336
session_id: String, - 337
commitment_id: String, - 338
escalation: vak_intent::Escalation, - 339
} - 340
- 341
impl DeferringApprover { - 342
pub fn new( - 343
inner: Option<std::sync::Arc<dyn vak_agent::Approver>>, - 344
shared_home: std::path::PathBuf, - 345
sessions_home: std::path::PathBuf, - 346
session_id: String, - 347
commitment_id: String, - 348
escalation: vak_intent::Escalation, - 349
) -> Self { - 350
DeferringApprover { - 351
inner, - 352
shared_home, - 353
sessions_home, - 354
session_id, - 355
commitment_id, - 356
escalation, - 357
} - 358
} - 359
} - 360
- 361
#[async_trait::async_trait] - 362
impl vak_agent::Approver for DeferringApprover { - 363
async fn approve(&self, tool: &str, args_json: &str, reason: &str) -> bool { - 364
if let Some(inner) = &self.inner - 365
&& inner.answerable() - 366
{ - 367
return inner.approve(tool, args_json, reason).await; - 368
} - 369
let question = format!("`{tool}` needs approval: {reason}"); - 370
let body = format!("{question}\n\nArguments:\n{args_json}"); - 371
match crate::commitments::defer_for_human( - 372
&self.sessions_home, - 373
&self.commitment_id, - 374
&question, - 375
None, - 376
self.escalation.clone(), - 377
) { - 378
Ok(question_id) => { - 379
let _ = crate::inbox::record( - 380
&self.shared_home, - 381
crate::inbox::Kind::ApprovalPending, - 382
&format!("Decision needed for {}", self.commitment_id), - 383
&format!( - 384
"{body}\n\nquestion: {question_id}\ncommitment: {}", - 385
self.commitment_id - 386
), - 387
Some(&self.session_id), - 388
None, - 389
); - 390
} - 391
Err(error) => { - 392
let _ = crate::inbox::record( - 393
&self.shared_home, - 394
crate::inbox::Kind::ApprovalDenied, - 395
&format!("Gate denied for {}", self.commitment_id), - 396
&format!("{body}\n\ncould not suspend the commitment: {error}"), - 397
Some(&self.session_id), - 398
None, - 399
); - 400
} - 401
} - 402
// Fail closed, exactly as before: the answer arrives through the - 403
// inbox and the commitment resumes from there. - 404
false - 405
} - 406
- 407
fn answerable(&self) -> bool { - 408
self.inner.as_ref().is_some_and(|inner| inner.answerable()) - 409
} - 410
} - 411
- 412
/// Pre-authorization from the envelopes on the commitments this turn works - 413
/// on, consulted at an `Ask` gate (`vak_agent::EnvelopeCheck`). - 414
/// - 415
/// The grant is read from the ledger on every call, never captured, so a - 416
/// revocation, an expiry, a closure or an exhausted spend limit takes effect - 417
/// at the very next gate (invariant 11 applied to delegation). A call is - 418
/// covered only when every path it names is inside the envelope's scope and - 419
/// its tool is in the envelope's tool list, if it has one - 420
/// (`Envelope::covers`). The caller installs this only for a delegated turn - 421
/// with nothing irreversible in it: irreversible work reaches a human - 422
/// whatever was delegated (invariant 32). - 423
pub fn envelope_check( - 424
sessions_home: PathBuf, - 425
commitment_ids: Vec<String>, - 426
workspace: PathBuf, - 427
) -> vak_agent::EnvelopeCheck { - 428
std::sync::Arc::new(move |tool, input| { - 429
let ledger = vak_commit::CommitmentLedger::new(&sessions_home); - 430
let paths = action_paths(input, &workspace); - 431
let now = chrono::Utc::now(); - 432
commitment_ids.iter().find_map(|id| { - 433
let commitment = ledger.get(id).ok().flatten()?; - 434
if commitment.phase.is_terminal() { - 435
return None; - 436
} - 437
let envelope = commitment.envelope?; - 438
let within_budget = envelope - 439
.spend_limit_usd - 440
.is_none_or(|limit| limit.is_finite() && commitment.spend_usd < limit); - 441
(within_budget && envelope.is_live(now) && envelope.covers(tool, &paths)) - 442
.then_some(envelope.envelope_id) - 443
}) - 444
}) - 445
} - 446
- 447
/// The paths a call names, made workspace-relative where they are inside the - 448
/// workspace. Anything else is passed through as written, where - 449
/// `Envelope::covers` treats it as uncoverable. - 450
fn action_paths(input: &serde_json::Value, workspace: &Path) -> Vec<String> { - 451
let mut raw: Vec<&str> = ["path", "file_path"] - 452
.iter() - 453
.filter_map(|key| input.get(*key).and_then(serde_json::Value::as_str)) - 454
.collect(); - 455
if let Some(paths) = input.get("paths").and_then(serde_json::Value::as_array) { - 456
raw.extend(paths.iter().filter_map(serde_json::Value::as_str)); - 457
} - 458
let roots: Vec<PathBuf> = [Some(workspace.to_path_buf()), workspace.canonicalize().ok()] - 459
.into_iter() - 460
.flatten() - 461
.collect(); - 462
raw.into_iter() - 463
.map(|path| { - 464
let candidate = Path::new(path); - 465
if candidate.is_absolute() - 466
&& let Some(relative) = roots - 467
.iter() - 468
.find_map(|root| candidate.strip_prefix(root).ok()) - 469
{ - 470
return relative.to_string_lossy().into_owned(); - 471
} - 472
path.to_string() - 473
}) - 474
.collect() - 475
} - 476
- 477
/// The engagement's contribution to the prompt, as a runtime section. - 478
/// - 479
/// Code-owned, like the `Surface:` line: it sits beside the other generated - 480
/// sections so no editable layer can name it, rewrite it, or delete it. - 481
pub fn intent_section(intent: &Intent) -> String { - 482
match intent.engagement.posture.note.as_deref() { - 483
Some(note) if !note.trim().is_empty() => note.to_string(), - 484
_ => String::new(), - 485
} - 486
} - 487
- 488
/// Assert the whole projection narrows. Used by tests and debug builds. - 489
pub fn projection_is_narrowing(limits: &Limits) -> bool { - 490
limits.is_at_most(&Limits::unrestricted()) - 491
} - 492
- 493
#[cfg(test)] - 494
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 495
mod tests { - 496
use super::*; - 497
- 498
/// Intent may force a gate the configuration skipped; it may never skip - 499
/// one the configuration wanted. - 500
#[test] - 501
fn approval_composition_only_tightens() { - 502
use vak_config::ApprovalMode::*; - 503
for configured in [Ask, ApproveSafe, AutoApprove] { - 504
for ceiling in ApprovalCeiling::ALL { - 505
let result = approval_mode(configured, ceiling, true); - 506
assert!( - 507
approval_rank(result) <= approval_rank(configured), - 508
"{configured:?} + {ceiling:?} loosened to {result:?}" - 509
); - 510
} - 511
} - 512
assert_eq!( - 513
approval_mode(AutoApprove, ApprovalCeiling::Ask, true), - 514
Ask, - 515
"an irreversible turn must reach a human even under auto-approve" - 516
); - 517
} - 518
- 519
#[test] - 520
fn disabling_posture_leaves_the_configured_approval_mode_untouched() { - 521
use vak_config::ApprovalMode::*; - 522
assert_eq!( - 523
approval_mode(AutoApprove, ApprovalCeiling::Ask, false), - 524
AutoApprove - 525
); - 526
} - 527
- 528
#[test] - 529
fn permission_composition_only_tightens() { - 530
use vak_config::PermissionMode::*; - 531
for configured in [ReadOnly, WorkspaceWrite, FullAccess] { - 532
for ceiling in PermissionCeiling::ALL { - 533
let result = permission_mode(configured, ceiling); - 534
assert!(result.rank() <= configured.rank()); - 535
} - 536
} - 537
assert_eq!( - 538
permission_mode(FullAccess, PermissionCeiling::ReadOnly), - 539
ReadOnly - 540
); - 541
// And an envelope cannot promote a read-only workspace. - 542
assert_eq!( - 543
permission_mode(ReadOnly, PermissionCeiling::FullAccess), - 544
ReadOnly - 545
); - 546
} - 547
- 548
fn grant( - 549
home: &Path, - 550
workspace: &Path, - 551
path_scope: &[&str], - 552
tool_scope: &[&str], - 553
) -> (String, vak_intent::Envelope) { - 554
let ledger = vak_commit::CommitmentLedger::new(home); - 555
let id = ledger - 556
.open_commitment(vak_commit::spec_from_reading( - 557
"keep the docs current", - 558
vak_intent::Reading::general(), - 559
Vec::new(), - 560
workspace.to_path_buf(), - 561
vak_commit::Economics::default(), - 562
)) - 563
.unwrap(); - 564
let envelope = vak_intent::Envelope { - 565
envelope_id: "env-1".into(), - 566
granted_by: "owner".into(), - 567
granted_at: chrono::Utc::now(), - 568
expires_at: None, - 569
spend_limit_usd: None, - 570
path_scope: path_scope.iter().map(|s| s.to_string()).collect(), - 571
tool_scope: tool_scope.iter().map(|s| s.to_string()).collect(), - 572
permission_ceiling: PermissionCeiling::WorkspaceWrite, - 573
escalation: vak_intent::Escalation::WaitIndefinitely, - 574
revoked_at: None, - 575
}; - 576
ledger - 577
.append(&vak_commit::Event::new( - 578
&id, - 579
vak_commit::EventKind::EnvelopeGranted { - 580
envelope: Box::new(envelope.clone()), - 581
}, - 582
)) - 583
.unwrap(); - 584
(id, envelope) - 585
} - 586
- 587
#[test] - 588
fn an_envelope_covers_only_what_its_scope_names() { - 589
let home = tempfile::tempdir().unwrap(); - 590
let workspace = tempfile::tempdir().unwrap(); - 591
let (id, _) = grant( - 592
home.path(), - 593
workspace.path(), - 594
&["docs/**"], - 595
&["write", "edit"], - 596
); - 597
let check = envelope_check( - 598
home.path().to_path_buf(), - 599
vec![id], - 600
workspace.path().to_path_buf(), - 601
); - 602
let covered = |tool: &str, input: serde_json::Value| check(tool, &input).is_some(); - 603
assert!(covered( - 604
"edit", - 605
serde_json::json!({"path": "docs/guide.md"}) - 606
)); - 607
// An absolute path inside the workspace is the same file. - 608
let absolute = workspace.path().join("docs/guide.md"); - 609
assert!(covered("write", serde_json::json!({"path": absolute}))); - 610
// Outside the path scope, outside the tool scope, climbing out, or - 611
// naming no path at all: not covered, so the gate asks as before. - 612
assert!(!covered("edit", serde_json::json!({"path": "src/main.rs"}))); - 613
assert!(!covered( - 614
"bash", - 615
serde_json::json!({"command": "rm -rf docs"}) - 616
)); - 617
assert!(!covered( - 618
"edit", - 619
serde_json::json!({"path": "docs/../.env"}) - 620
)); - 621
assert!(!covered("edit", serde_json::json!({"path": "/etc/hosts"}))); - 622
assert!(!covered("edit", serde_json::json!({}))); - 623
} - 624
- 625
/// The grant is read at every gate: a revocation applies to the very next - 626
/// call, not to the next session. - 627
#[test] - 628
fn a_revoked_envelope_stops_covering_at_the_next_gate() { - 629
let home = tempfile::tempdir().unwrap(); - 630
let workspace = tempfile::tempdir().unwrap(); - 631
let (id, envelope) = grant(home.path(), workspace.path(), &[], &[]); - 632
let check = envelope_check( - 633
home.path().to_path_buf(), - 634
vec![id.clone()], - 635
workspace.path().to_path_buf(), - 636
); - 637
let input = serde_json::json!({"path": "notes.md"}); - 638
assert_eq!(check("write", &input), Some(envelope.envelope_id.clone())); - 639
vak_commit::CommitmentLedger::new(home.path()) - 640
.append(&vak_commit::Event::new( - 641
&id, - 642
vak_commit::EventKind::EnvelopeRevoked { - 643
envelope_id: envelope.envelope_id, - 644
by: "owner".into(), - 645
}, - 646
)) - 647
.unwrap(); - 648
assert_eq!(check("write", &input), None); - 649
} - 650
- 651
#[test] - 652
fn spend_ceilings_take_the_smaller() { - 653
assert_eq!(spend_ceiling(Some(5.0), Some(1.0)), Some(1.0)); - 654
assert_eq!(spend_ceiling(Some(1.0), Some(5.0)), Some(1.0)); - 655
assert_eq!(spend_ceiling(None, Some(2.0)), Some(2.0)); - 656
assert_eq!(spend_ceiling(Some(2.0), None), Some(2.0)); - 657
assert_eq!(spend_ceiling(None, None), None); - 658
} - 659
- 660
/// An unnamed surface must not be mistaken for a human at a terminal. - 661
#[test] - 662
fn an_unknown_surface_is_not_assumed_interactive() { - 663
assert_eq!(intent_surface(&Surface::Unknown), IntentSurface::Server); - 664
assert_eq!(intent_surface(&Surface::Background), IntentSurface::Cron); - 665
assert_eq!( - 666
intent_surface(&Surface::Chat { - 667
channel: "telegram".into() - 668
}), - 669
IntentSurface::Chat - 670
); - 671
} - 672
- 673
#[test] - 674
fn modality_filtering_abstains_without_declared_hints() { - 675
let required = BTreeSet::from([vak_intent::Modality::Image]); - 676
// No hints configured: we do not know, so we do not restrict. - 677
assert!(leg_supports_modalities("some-model", &required, &[])); - 678
// With hints, only matching legs qualify. - 679
let hints = vec!["vision".to_string()]; - 680
assert!(leg_supports_modalities("big-vision-1", &required, &hints)); - 681
assert!(!leg_supports_modalities("text-only-2", &required, &hints)); - 682
// A text-only turn is unconstrained either way. - 683
assert!(leg_supports_modalities( - 684
"text-only-2", - 685
&BTreeSet::new(), - 686
&hints - 687
)); - 688
} - 689
} - 690
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.