From b8c20fa2cca728cc5b17e18a7bdbc98e92b7315b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Sep 2026 01:37:27 +0000 Subject: [PATCH] chore: sync public mirror from internal --- .repository-projection.json | 6 +- vendor/dex-loop/src/context.rs | 18 ++ vendor/dex-loop/src/engine.rs | 94 +++---- vendor/dex-loop/src/event.rs | 26 +- vendor/dex-loop/src/lib.rs | 7 +- vendor/dex-loop/tests/scenarios.rs | 330 +++++++++++++----------- vendor/dex-loop/tests/sim/invariants.rs | 41 +++ vendor/dex-loop/tests/support/mod.rs | 1 + 8 files changed, 307 insertions(+), 216 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 0394a2a90..206f82278 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "5c0ef43f86d41311f868236ebbc050f14969e263", + "sourceSha": "fac55f7217fbc6e14c155ecfc00aed5c8c31adca", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "931b3d60176d650c886e2ec3802031569808b470", + "priorProjectedBase": "e9728f9fd83065904e209c56238a89291741bd74", "definitionDigest": "82936441c776e3e8edb5d215a75007ec9714a233f489d460075d79d5ef5ba32f", "toolDigest": "c244d99199a7ae3eb8ff644a99462163c23b0bb6a83ef50af01efbdca0b81d04", - "contentDigest": "11f79bfdda6fbb50a86620b4528c84bbabc195dc16b6c97058a812bcfadeb395", + "contentDigest": "40c32f0bd494617b1e7297f7daf817ddd6861452f132690578f73452179f2900", "publicationEligible": true } diff --git a/vendor/dex-loop/src/context.rs b/vendor/dex-loop/src/context.rs index 836d66d04..597918f79 100644 --- a/vendor/dex-loop/src/context.rs +++ b/vendor/dex-loop/src/context.rs @@ -510,6 +510,24 @@ impl Context { }; } } + Event::AutoApproved { + call, + approval, + args_digest, + .. + } => { + // The receipt is the decision: the call is dispatchable at + // once, and a replay adopts the same digest the engine bound. + if let Some(state) = self.state_mut(call) { + *state = CallState::Parked { + approval: approval.clone(), + decision: Some(Decision { + approved: true, + args_digest: args_digest.clone(), + }), + }; + } + } Event::Question { call, .. } => { if let Some(state) = self.state_mut(call) { *state = CallState::Asked { answer: None }; diff --git a/vendor/dex-loop/src/engine.rs b/vendor/dex-loop/src/engine.rs index 375f4817e..b0eaffa27 100644 --- a/vendor/dex-loop/src/engine.rs +++ b/vendor/dex-loop/src/engine.rs @@ -2,8 +2,10 @@ //! //! Per step: `StepStarted` → model stream (text to the log as it arrives) → //! `ModelStepCompleted` (the commit point: every proposed call, with full -//! arguments) → per call, in order: policy → approval → `ToolStarted` → -//! effect → `ToolFinished`. The turn ends when a step proposes no calls. +//! arguments) → per call, in order: policy → (auto-approval receipt) → +//! `ToolStarted` → effect → `ToolFinished`. The turn ends when a step +//! proposes no calls. No call ever parks for a human: a `NeedsApproval` +//! verdict is granted at once and recorded as `AutoApproved`. use std::pin::pin; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -16,8 +18,8 @@ use crate::budget::{Budget, BudgetAxis}; use crate::compaction::{Compactor, NoCompaction}; use crate::context::{CallState, Context, Decision, Status}; use crate::event::{ - ApprovalId, ApprovalMode, CallId, ErrorCode, Event, HEADLESS_AUTO_APPROVER, Outcome, - PrincipalId, ProposedCall, ToolName, ToolResult, TurnId, + AUTO_APPROVER, ApprovalId, CallId, ErrorCode, Event, Outcome, PrincipalId, ProposedCall, + ToolName, ToolResult, TurnId, }; use crate::ports::{ Claim, Effects, ExecutorKind, Fenced, GovernanceClass, Log, Model, ModelChunk, ModelError, @@ -71,7 +73,9 @@ const DEADLINE_MUTATION: &str = "outcome unknown: the call did not finish within pub enum Exit { /// The model answered without tool calls. Done, - /// Waiting for `Event::ApprovalDecided`; call `run` again after it lands. + /// Legacy: waiting for `Event::ApprovalDecided`. The engine no longer + /// returns it (no call parks for a human); hosts keep the arm so an + /// older binary's exit still matches. Parked(ApprovalId), /// Waiting for `Event::Answer` to this call; call `run` again after it lands. Asked(CallId), @@ -538,13 +542,20 @@ where approval, decision: None, }) => { + // A call an older deploy parked for a human and nobody + // decided. No human decides any more: grant it now, + // under the same approval id, so the thread resumes + // instead of waiting forever. if self .flush(ctx, &calls, &mut wave, cancel, run_started) .await? { break; } - return Ok(Some(Exit::Parked(approval))); + let summary = self + .offered_spec(ctx, &call.tool) + .map_or_else(|| call.tool.to_string(), |spec| spec.label); + Some(self.auto_approve(ctx, call, approval, summary).await?) } Some(CallState::Parked { decision: Some(decision), @@ -641,6 +652,9 @@ where if self.refuse_uncertain_repeat(ctx, call, &spec).await? { continue; } + // Policy asked for approval: no human is asked. The call is + // granted at once and the receipt goes to the log before the + // effect; the pending wave runs first so effects keep order. if let (None, Verdict::NeedsApproval { approval, summary }) = (decision, verdict) { if self .flush(ctx, &calls, &mut wave, cancel, run_started) @@ -648,12 +662,7 @@ where { break; } - if !self - .request_approval(ctx, call, approval.clone(), summary) - .await? - { - return Ok(Some(Exit::Parked(approval))); - } + self.auto_approve(ctx, call, approval, summary).await?; } if spec.executor == ExecutorKind::User { @@ -755,12 +764,7 @@ where } let approval = ApprovalId::new(format!("client-{}", call.id)); let summary = format!("Run {} in your browser", spec.label); - if !self - .request_approval(ctx, call, approval.clone(), summary) - .await? - { - return Ok(ClientToolOutcome::Exit(Exit::Parked(approval))); - } + self.auto_approve(ctx, call, approval, summary).await?; } if self.flush(ctx, calls, wave, cancel, run_started).await? { return Ok(ClientToolOutcome::Break); @@ -1119,48 +1123,34 @@ where } } - /// Appends and observes. - /// Logs an `ApprovalRequested` for a call policy said must ask. Returns - /// `true` when the call is already decided and dispatch continues. - /// - /// An `Interactive` turn parks (returns `false`) until a human's - /// `ApprovalDecided` arrives. A `Headless` turn has no human, so the - /// request and an approving `ApprovalDecided` from - /// `HEADLESS_AUTO_APPROVER` are appended together: the audit trail is the - /// same pair a human approval would leave. Only reached for a - /// `NeedsApproval` verdict; `Deny` was handled before this point and stays - /// denied. - async fn request_approval( + /// Grants a call policy said must ask, at once, and writes the receipt. + /// No human is ever asked: the `AutoApproved` row (call, approval id, + /// argument digest, summary, `AUTO_APPROVER`) is the durable record of + /// what ran, and a replay adopts it instead of asking policy to grant + /// again. Only reached for a `NeedsApproval` verdict or a legacy parked + /// call; `Deny` was handled before this point and stays denied. + async fn auto_approve( &self, ctx: &mut Context, call: &ProposedCall, approval: ApprovalId, summary: String, - ) -> Result { - let mut events = vec![Event::ApprovalRequested { - call: call.id.clone(), - approval: approval.clone(), - args_digest: call.args_digest.clone(), - summary, - }]; - let headless = ctx.approval_mode() == ApprovalMode::Headless; - if headless { - events.push(Event::ApprovalDecided { + ) -> Result { + self.emit( + ctx, + vec![Event::AutoApproved { call: call.id.clone(), approval, args_digest: call.args_digest.clone(), - approved: true, - principal: PrincipalId::new(HEADLESS_AUTO_APPROVER), - }); - } - // The engine wrote the decision itself: it must not move the control - // cursor past a `Steer` or `Interrupt` that landed in between. - let control = ctx.control_cursor(); - self.emit(ctx, events).await?; - if headless { - ctx.rewind_control(control); - } - Ok(headless) + summary, + principal: PrincipalId::new(AUTO_APPROVER), + }], + ) + .await?; + Ok(Decision { + approved: true, + args_digest: call.args_digest.clone(), + }) } async fn emit(&self, ctx: &mut Context, events: Vec) -> Result<(), Fenced> { diff --git a/vendor/dex-loop/src/event.rs b/vendor/dex-loop/src/event.rs index 5c7816ff6..90f9de25b 100644 --- a/vendor/dex-loop/src/event.rs +++ b/vendor/dex-loop/src/event.rs @@ -332,9 +332,16 @@ impl ApprovalMode { } /// The principal recorded on an `ApprovalDecided` the engine wrote itself -/// for a `Headless` turn. +/// for a `Headless` turn. Kept so stored rows still decode; the engine no +/// longer writes it (see `AUTO_APPROVER`). pub const HEADLESS_AUTO_APPROVER: &str = "policy:headless_auto_approve"; +/// The principal recorded on every `AutoApproved` receipt. No human approves +/// a Dex tool call: a `NeedsApproval` verdict is granted by policy at once, +/// on every surface and for every principal, and the receipt is the audit +/// record of what ran, for whom, and under which argument digest. +pub const AUTO_APPROVER: &str = "policy:auto_approve"; + /// One row in a thread's log. Hosts append the ingress events (`UserMessage`, /// `Steer`, `Interrupt`, `ApprovalDecided`, `Answer`, and optionally /// `ToolProgress`); the engine appends everything else. @@ -464,13 +471,28 @@ pub enum Event { output: Output, receipt: Option, }, - /// The turn is parked until an `ApprovalDecided` for this call arrives. + /// Legacy: the turn was parked until an `ApprovalDecided` for this call + /// arrived. Never emitted any more (policy grants at once and writes + /// `AutoApproved`); still decoded from stored history. A call left in + /// this state by an older deploy is auto-approved on its next run. ApprovalRequested { call: CallId, approval: ApprovalId, args_digest: String, summary: String, }, + /// The durable receipt for a call policy would once have parked for a + /// human: granted at once by `AUTO_APPROVER`, never shown as a prompt. + /// `summary` is what the approver would have read (a guardian flag is + /// carried here too); `args_digest` binds the receipt to the exact + /// arguments that ran. Not a control event: the engine writes it itself. + AutoApproved { + call: CallId, + approval: ApprovalId, + args_digest: String, + summary: String, + principal: PrincipalId, + }, /// The turn is parked until an `Answer` for this call arrives. Question { call: CallId, diff --git a/vendor/dex-loop/src/lib.rs b/vendor/dex-loop/src/lib.rs index 18ae89167..fdc10aff5 100644 --- a/vendor/dex-loop/src/lib.rs +++ b/vendor/dex-loop/src/lib.rs @@ -33,9 +33,10 @@ pub use compaction::{Compaction, Compactor, NoCompaction, Summarize, Threshold}; pub use context::{Context, Entry, Message}; pub use engine::{CUT_OFF_NOTICE, DEFAULT_TOOL_CALL_DEADLINE, Engine, Exit, TOOLS_SEARCH}; pub use event::{ - ApprovalId, ApprovalMode, ArtifactRef, CallId, ClientToolSpec, Cursor, ErrorCode, Event, - HEADLESS_AUTO_APPROVER, MessageId, Outcome, Output, OutputRef, PrincipalId, ProposedCall, - ProviderReasoning, ReceiptId, ThreadId, ToolName, ToolResult, TurnId, Usage, args_digest, + AUTO_APPROVER, ApprovalId, ApprovalMode, ArtifactRef, CallId, ClientToolSpec, Cursor, + ErrorCode, Event, HEADLESS_AUTO_APPROVER, MessageId, Outcome, Output, OutputRef, PrincipalId, + ProposedCall, ProviderReasoning, ReceiptId, ThreadId, ToolName, ToolResult, TurnId, Usage, + args_digest, }; pub use ports::{ Claim, Effects, ExecutorKind, Fenced, GovernanceClass, Log, Model, ModelChunk, ModelError, diff --git a/vendor/dex-loop/tests/scenarios.rs b/vendor/dex-loop/tests/scenarios.rs index 3352959d5..388ced1b4 100644 --- a/vendor/dex-loop/tests/scenarios.rs +++ b/vendor/dex-loop/tests/scenarios.rs @@ -7,8 +7,9 @@ mod support; use std::time::{Duration, Instant}; use dex_loop::{ - ApprovalId, ApprovalMode, Budget, CUT_OFF_NOTICE, CancellationToken, Engine, Event, Exit, - Lexicon, ModelError, OutputRef, ProposedCall, Threshold, ToolName, ToolResult, TurnId, Verdict, + ApprovalId, ApprovalMode, Budget, CUT_OFF_NOTICE, CancellationToken, Cursor, Engine, Event, + Exit, Fenced, Lexicon, ModelError, OutputRef, PrincipalId, ProposedCall, Threshold, ToolName, + ToolResult, TurnId, Verdict, }; use serde_json::json; use support::*; @@ -191,10 +192,11 @@ async fn mutating_call_runs_serially_after_the_wave() { ); } -// 4a. Park B with C pending, crash, rehydrate: after approval, policy runs -// again, then B and C run with their original arguments, read from the log. +// 4a. Policy asks for approval on B: nobody is asked. The receipt lands +// before B runs, bound to B's digest, and the rest of the step runs in +// order with the original arguments. #[tokio::test] -async fn park_crash_rehydrate_runs_the_rest_of_the_step_with_original_args() { +async fn an_approval_class_call_is_granted_at_once_recorded_and_runs_in_order() { let log = FakeLog::default(); let model = FakeModel::new(vec![ vec![ @@ -210,10 +212,7 @@ async fn park_crash_rehydrate_runs_the_rest_of_the_step_with_original_args() { let mut ctx = log.start_turn("t1", "email them"); let cancel = CancellationToken::new(); - assert_eq!( - engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(ApprovalId::new("ap-1"))) - ); + assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); assert_eq!( log.shapes_after(1), strings(&[ @@ -221,41 +220,7 @@ async fn park_crash_rehydrate_runs_the_rest_of_the_step_with_original_args() { "completed::[t1-1-0,t1-1-1,t1-1-2]", "started:t1-1-0", "finished:t1-1-0:ok", - "approval:t1-1-1", - ]) - ); - assert_eq!(tools.run_ids(), strings(&["t1-1-0"])); - // Parked with no decision: running again changes nothing. - assert_eq!( - engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(ApprovalId::new("ap-1"))) - ); - let parked_len = log.len(); - - // A decision for another approval id authorizes nothing. - log.host_append(Event::ApprovalDecided { - call: call_id("t1", 1, 1), - approval: ApprovalId::new("ap-other"), - args_digest: log.requested_digest(&call_id("t1", 1, 1)), - approved: true, - principal: alice(), - }); - // -- crash: a fresh engine and context from the log -- - let engine = support::engine(&log, &model, &tools, budget()); - let mut ctx = log.rehydrate(); - assert_eq!( - engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(ApprovalId::new("ap-1"))) - ); - assert_eq!(log.len(), parked_len + 1); - - log.decide(&call_id("t1", 1, 1), "ap-1", true); - let mut ctx = log.rehydrate(); - assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); - - assert_eq!( - log.shapes_after(parked_len + 2), - strings(&[ + "auto_approved:t1-1-1", "started:t1-1-1", "finished:t1-1-1:ok", "started:t1-1-2", @@ -266,19 +231,39 @@ async fn park_crash_rehydrate_runs_the_rest_of_the_step_with_original_args() { "final:sent", ]) ); + let receipt = log + .events() + .into_iter() + .find(|event| matches!(event, Event::AutoApproved { .. })) + .expect("the receipt"); + assert_eq!( + receipt, + Event::AutoApproved { + call: call_id("t1", 1, 1), + approval: ApprovalId::new("ap-1"), + args_digest: dex_loop::args_digest(&json!({"key": "w", "to": "ops@example.com"})), + summary: "Approve ap-1".into(), + principal: PrincipalId::new(dex_loop::AUTO_APPROVER), + } + ); + assert!( + !log.events() + .iter() + .any(|event| matches!(event, Event::ApprovalRequested { .. })), + "no approval request is ever written" + ); assert_eq!(tools.run_ids(), strings(&["t1-1-0", "t1-1-1", "t1-1-2"])); assert_eq!( tools.run_of(&call_id("t1", 1, 1)).args, json!({"key": "w", "to": "ops@example.com"}) ); - assert_eq!(tools.run_of(&call_id("t1", 1, 2)).args, json!({"key": "b"})); - // Policy ran for B at proposal and again on resume, and once for C. + // Policy ran once per call: nothing resumed, so nothing re-checked. let checks: Vec = tools .policy_checks() .into_iter() .map(|(call, _)| call) .collect(); - assert_eq!(checks, strings(&["t1-1-0", "t1-1-1", "t1-1-1", "t1-1-2"])); + assert_eq!(checks, strings(&["t1-1-0", "t1-1-1", "t1-1-2"])); assert_eq!( view(&model.seen()[1])[2..], strings(&[ @@ -290,125 +275,187 @@ async fn park_crash_rehydrate_runs_the_rest_of_the_step_with_original_args() { assert_eq!(log.rehydrate(), ctx); } -// 4b. A declined approval becomes a result the model sees, and the rest of the -// step still runs. This variant resumes on the warm context. +// 4b. A crash between the receipt and `ToolStarted`: the rehydrated engine +// adopts the receipt (no second one, no second policy grant) and runs the +// call once with its original arguments. #[tokio::test] -async fn declined_approval_is_a_visible_result_and_the_step_continues() { +async fn a_receipt_survives_a_crash_and_the_call_runs_once_after_rehydrate() { let log = FakeLog::default(); let model = FakeModel::new(vec![ - vec![ - call("send_email", json!({"key": "w"})), - call("search", json!({"key": "b"})), - ], - vec![text("not sent")], + vec![call("send_email", json!({"key": "w"}))], + vec![text("sent")], ]); - let tools = FakeTools::new(vec![read_tool("search"), write_tool("send_email")]) - .verdict("send_email", approval("ap-1")); + let tools = + FakeTools::new(vec![write_tool("send_email")]).verdict("send_email", approval("ap-1")); let engine = engine(&log, &model, &tools, budget()); let mut ctx = log.start_turn("t1", "email them"); let cancel = CancellationToken::new(); - - assert_eq!( + // user, step, completed, auto_approved land; the `ToolStarted` write is + // refused, which is the crash. + log.fence_after(3); + assert!(matches!( engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(ApprovalId::new("ap-1"))) + Err(Fenced { .. }) + )); + assert_eq!( + log.shapes_after(1), + strings(&["step:1", "completed::[t1-1-0]", "auto_approved:t1-1-0"]) ); - let parked_len = log.len(); - log.decide(&call_id("t1", 1, 0), "ap-1", false); - assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); + assert!(tools.runs().is_empty(), "nothing ran before the crash"); + log.fence_after(usize::MAX); + let engine = support::engine(&log, &model, &tools, budget()); + let mut ctx = log.rehydrate(); + assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); assert_eq!( - log.shapes_after(parked_len + 1), + log.shapes_after(4), strings(&[ - "finished:t1-1-0:err", - "started:t1-1-1", - "finished:t1-1-1:ok", + "started:t1-1-0", + "finished:t1-1-0:ok", "step:2", - "delta:not sent", - "completed:not sent:[]", - "final:not sent", + "delta:sent", + "completed:sent:[]", + "final:sent", ]) ); - assert_eq!(tools.run_ids(), strings(&["t1-1-1"])); + assert_eq!(tools.run_ids(), strings(&["t1-1-0"])); assert_eq!( - view(&model.seen()[1])[2..], + log.events() + .iter() + .filter(|event| matches!(event, Event::AutoApproved { .. })) + .count(), + 1, + "one receipt per call, across the crash" + ); + assert_eq!(log.rehydrate(), ctx); +} + +// 4c. A call an older deploy parked for a human (an `ApprovalRequested` with +// no decision) is granted on its next run, under the same approval id, and +// the step continues; the thread is never stranded. +#[tokio::test] +async fn a_legacy_parked_call_is_granted_on_rehydrate_and_the_step_continues() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![], vec![text("sent")]]); + let tools = FakeTools::new(vec![read_tool("search"), write_tool("send_email")]) + .verdict("send_email", approval("ap-1")); + let send = ProposedCall::new( + call_id("t1", 1, 0), + ToolName::new("send_email"), + json!({"key": "w"}), + alice(), + ); + let search = ProposedCall::new( + call_id("t1", 1, 1), + ToolName::new("search"), + json!({"key": "b"}), + alice(), + ); + for event in [ + Event::UserMessage { + turn: TurnId::new("t1"), + message_id: None, + principal: alice(), + text: "email them".into(), + attachments: vec![], + client_tools: vec![], + authorized_tools: Vec::new(), + approval_mode: dex_loop::ApprovalMode::Interactive, + }, + Event::StepStarted { + step: 1, + control_through: Cursor::START, + }, + Event::ModelStepCompleted { + step: 1, + text: String::new(), + calls: vec![send.clone(), search], + reasoning: None, + }, + Event::ApprovalRequested { + call: send.id.clone(), + approval: ApprovalId::new("ap-1"), + args_digest: send.args_digest.clone(), + summary: "Approve ap-1".into(), + }, + ] { + log.host_append(event); + } + let engine = engine(&log, &model, &tools, budget()); + let mut ctx = log.rehydrate(); + let cancel = CancellationToken::new(); + assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); + assert_eq!( + log.shapes_after(4), strings(&[ - "tool:t1-1-0:err:denied: the approver declined this call", - "tool:t1-1-1:ok:out/t1-1-1", + "auto_approved:t1-1-0", + "started:t1-1-0", + "finished:t1-1-0:ok", + "started:t1-1-1", + "finished:t1-1-1:ok", + "step:2", + "delta:sent", + "completed:sent:[]", + "final:sent", ]) ); + let receipt = log + .events() + .into_iter() + .find(|event| matches!(event, Event::AutoApproved { .. })) + .expect("the receipt"); + assert!( + matches!(&receipt, Event::AutoApproved { approval, args_digest, .. } + if approval.as_str() == "ap-1" && args_digest == &send.args_digest), + "{receipt:?}" + ); + assert_eq!(tools.run_ids(), strings(&["t1-1-0", "t1-1-1"])); + assert_eq!(tools.run_of(&send.id).args, json!({"key": "w"})); assert_eq!(log.rehydrate(), ctx); } -// 4c. Approval is necessary, not sufficient: a grant revoked while the call -// was parked denies the approved call on resume. +// 4d. Policy's own denial still denies: a grant is not an override, and a +// stale human decision in the log (from a client that still sends one) +// changes nothing about a call that already has its receipt. #[tokio::test] -async fn revoked_grant_denies_an_approved_call_on_resume() { +async fn a_policy_denial_still_denies_and_a_stale_decision_is_inert() { let log = FakeLog::default(); let model = FakeModel::new(vec![ vec![call("send_email", json!({"key": "w"}))], vec![text("could not send")], ]); - let tools = - FakeTools::new(vec![write_tool("send_email")]).verdict("send_email", approval("ap-1")); - let engine = engine(&log, &model, &tools, budget()); - let mut ctx = log.start_turn("t1", "email them"); - let cancel = CancellationToken::new(); - assert!(matches!( - engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(_)) - )); - - log.decide(&call_id("t1", 1, 0), "ap-1", true); - tools.set_verdict( + let tools = FakeTools::new(vec![write_tool("send_email")]).verdict( "send_email", Verdict::Deny("the mail grant was revoked".into()), ); - let mut ctx = log.rehydrate(); + let engine = engine(&log, &model, &tools, budget()); + let mut ctx = log.start_turn("t1", "email them"); + let cancel = CancellationToken::new(); assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); - + assert!(tools.runs().is_empty(), "a denied call still ran"); assert!( - tools.runs().is_empty(), - "a revoked grant still ran the call" + !log.events() + .iter() + .any(|event| matches!(event, Event::AutoApproved { .. })), + "a denial writes no receipt" ); assert_eq!( view(&model.seen()[1])[2..], strings(&["tool:t1-1-0:err:denied: the mail grant was revoked"]) ); -} -// 4d. An approval must match the proposed call's argument digest. -#[tokio::test] -async fn approval_decision_with_a_different_digest_is_denied() { - for approved in [true, false] { - let log = FakeLog::default(); - let model = FakeModel::new(vec![ - vec![call("send_email", json!({"key": "w"}))], - vec![text("no")], - ]); - let tools = - FakeTools::new(vec![write_tool("send_email")]).verdict("send_email", approval("ap-1")); - let engine = engine(&log, &model, &tools, budget()); - let mut ctx = log.start_turn("t1", "email them"); - let cancel = CancellationToken::new(); - assert!(matches!( - engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(_)) - )); - - log.host_append(Event::ApprovalDecided { - call: call_id("t1", 1, 0), - approval: ApprovalId::new("ap-1"), - args_digest: dex_loop::args_digest(&json!({"key": "other"})), - approved, - principal: alice(), - }); - assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); - assert!(tools.runs().is_empty()); - assert_eq!( - view(&model.seen()[1])[2..], - strings(&["tool:t1-1-0:err:denied: the approval does not match this call's arguments"]) - ); - } + let before = log.len(); + log.host_append(Event::ApprovalDecided { + call: call_id("t1", 1, 0), + approval: ApprovalId::new("ap-1"), + args_digest: dex_loop::args_digest(&json!({"key": "w"})), + approved: true, + principal: alice(), + }); + let mut ctx = log.rehydrate(); + assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Done)); + assert_eq!(log.len(), before + 1, "a stale decision appends nothing"); + assert!(tools.runs().is_empty()); } // 5. Bob steers in Alice's turn: the steer becomes Bob's user message before @@ -626,35 +673,6 @@ async fn interrupt_during_a_mutation_completes_it_then_stops() { ); } -// 6c. An Interrupt already in the log ends a parked turn without a token. -#[tokio::test] -async fn interrupt_event_ends_a_parked_turn() { - let log = FakeLog::default(); - let model = FakeModel::new(vec![vec![call("send_email", json!({}))]]); - let tools = - FakeTools::new(vec![write_tool("send_email")]).verdict("send_email", approval("ap-1")); - let engine = engine(&log, &model, &tools, budget()); - let mut ctx = log.start_turn("t1", "go"); - let cancel = CancellationToken::new(); - assert!(matches!( - engine.run(&mut ctx, &cancel).await, - Ok(Exit::Parked(_)) - )); - log.host_append(Event::Interrupt { principal: bob() }); - let mut ctx = log.rehydrate(); - assert_eq!(engine.run(&mut ctx, &cancel).await, Ok(Exit::Interrupted)); - assert_eq!( - log.shapes_after(3), - strings(&[ - "approval:t1-1-0", - "interrupt", - "finished:t1-1-0:err", - "interrupted", - ]) - ); - assert!(tools.runs().is_empty()); -} - // 7. Each budget axis stops the turn with budget_exhausted. async fn run_to_budget(budget: Budget, model: FakeModel) -> (FakeLog, FakeModel, Exit) { let log = FakeLog::default(); diff --git a/vendor/dex-loop/tests/sim/invariants.rs b/vendor/dex-loop/tests/sim/invariants.rs index 627cdf7d0..31717b4db 100644 --- a/vendor/dex-loop/tests/sim/invariants.rs +++ b/vendor/dex-loop/tests/sim/invariants.rs @@ -73,6 +73,7 @@ fn kind_str(event: &Event) -> &'static str { Event::ToolsExposed { .. } => "tools_exposed", Event::ToolFinished { .. } => "tool_finished", Event::ApprovalRequested { .. } => "approval_requested", + Event::AutoApproved { .. } => "auto_approved", Event::Question { .. } => "question", Event::ClientToolRequested { .. } => "client_tool_requested", Event::Compaction { .. } => "compaction", @@ -316,6 +317,46 @@ pub fn check_log( } } + // (3b) Auto-approval receipts: no call is ever parked for a human (no + // `ApprovalRequested` in a fresh trace), and every receipt is bound to + // the digest of the call it granted. + let mut proposed_digests: HashMap = HashMap::new(); + for (_, event) in events { + if let Event::ModelStepCompleted { calls, .. } = event { + for call in calls { + proposed_digests.insert(call.id.clone(), call.args_digest.clone()); + } + } + } + for (_, event) in events { + match event { + Event::ApprovalRequested { call, .. } => { + violations.push(Violation::new(format!( + "call {call} was parked for a human approval; policy grants at once" + ))); + } + Event::AutoApproved { + call, + args_digest, + principal, + .. + } => { + if proposed_digests.get(call) != Some(args_digest) { + violations.push(Violation::new(format!( + "call {call} has an auto-approval receipt whose digest is not the call's" + ))); + } + if principal.as_str() != dex_loop::AUTO_APPROVER { + violations.push(Violation::new(format!( + "call {call} has an auto-approval receipt from {principal}, want {}", + dex_loop::AUTO_APPROVER + ))); + } + } + _ => {} + } + } + // (6) Interrupt never turns a started call into "not run". for (call, history) in &calls { if history.started_at.is_none() { diff --git a/vendor/dex-loop/tests/support/mod.rs b/vendor/dex-loop/tests/support/mod.rs index 461179d92..13daec34a 100644 --- a/vendor/dex-loop/tests/support/mod.rs +++ b/vendor/dex-loop/tests/support/mod.rs @@ -755,6 +755,7 @@ pub fn shape(event: &Event) -> String { .. } => format!("finished:{call}:{}", outcome(*result)), Event::ApprovalRequested { call, .. } => format!("approval:{call}"), + Event::AutoApproved { call, .. } => format!("auto_approved:{call}"), Event::Question { call, text } => format!("question:{call}:{text}"), Event::ClientToolRequested { call, tool, .. } => format!("client_tool:{call}:{tool}"), Event::ClientToolResult {