Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"projection": "deixic-code",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
"sourceSha": "4c3e830640c4066c99303b7582fa2bdb74095f0d",
"sourceSha": "10a53803cedbfd671b5b3d4eee881522e7561c81",
"destinationRepository": "dx-corp/code",
"priorProjectedBase": "ca90e530cac35a9d63eac51f920d58891a511b21",
"priorProjectedBase": "ebbe67aa0f540c41f24041efddb6cfb7f9075224",
"definitionDigest": "82936441c776e3e8edb5d215a75007ec9714a233f489d460075d79d5ef5ba32f",
"toolDigest": "c244d99199a7ae3eb8ff644a99462163c23b0bb6a83ef50af01efbdca0b81d04",
"contentDigest": "f684b3dd9cbf2e4f672beaa7d0c536d87035881e1cdb7f5d28afeebe1e49b530",
"contentDigest": "03efaffc773c89ec597f75013e916338b0e87d2c3f534416fd784dd8e536f907",
"publicationEligible": true
}
1 change: 0 additions & 1 deletion deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ ignore = [

# --- Unsound (no known exploitable vulnerability reported here) ---
{ id = "RUSTSEC-2026-0002", reason = "lru 0.12.5: IterMut violates Stacked Borrows (Miri/unsafe-code soundness issue), pulled in by ratatui (production). Not a memory-safety issue under normal (non-Miri) execution; tracked for the next ratatui bump that picks up a fixed lru. expires: 2026-10-23" },
{ id = "RUSTSEC-2026-0285", reason = "rustls <0.23.45 accepts TLS 1.3 handshake messages at the wrong encryption level after a key change in the same record (GHSA-2mjx-qc3c-rqvc, CVSS 5.3). Fixed in rustls 0.23.45, published 2026-09-14; that upgrade also resolves aws-lc-rs 1.18.1 and aws-lc-sys 0.45.0, all still inside the 14-day dependency-admission cool-off in security/rust-dependency-admission.toml. Bump the lockfile once they age past it. expires: 2026-09-30" },
]

[licenses]
Expand Down
118 changes: 116 additions & 2 deletions packages/dex-host-rs/src/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
use std::path::Path;

use dex_loop::{
ApprovalMode, Budget, CancellationToken, Event, Exit, Lexicon, Log as _, Model, PrincipalId,
ThreadId, TurnId, rehydrate,
ApprovalMode, Budget, CancellationToken, ConfirmationDecision, Event, Exit, Lexicon, Log as _,
Model, PrincipalId, ThreadId, TurnId, rehydrate,
};

use crate::{LocalEffects, LocalLog, LocalTools};
Expand Down Expand Up @@ -150,6 +150,9 @@ async fn drive_to_completion<M: Model>(
call,
principal: principal.clone(),
text: UNATTENDED_ANSWER.to_owned(),
// An unattended assumption is text, never human action consent.
confirmation_decision: ConfirmationDecision::Unspecified,
args_digest: String::new(),
}])
.await
.map_err(|_fenced| {
Expand Down Expand Up @@ -240,6 +243,117 @@ mod tests {
}
}

#[tokio::test]
async fn unattended_question_answer_never_grants_typed_action_consent() {
use dex_loop::{ActionConfirmation, CallId, ProposedCall};

let state_root = TempDir::new().expect("state tempdir");
let workspace = TempDir::new().expect("workspace tempdir");
let principal = PrincipalId::new("alice");
let question_call = CallId::new("question-1");
let action = ProposedCall::new(
CallId::new("send-1"),
ToolName::new("mail.send"),
serde_json::json!({"recipient":"someone@example.com"}),
principal.clone(),
);
let question = ProposedCall::new(
question_call.clone(),
ToolName::new("person.ask"),
serde_json::json!({"text":"Send the message?"}),
principal.clone(),
);
let log = LocalLog::acquire(state_root.path().join("log"), &thread())
.await
.expect("acquire log");
// Resume a persisted question from an earlier host. The current local
// two-tool catalog cannot ask yet; the driver still owns this exit.
log.append(&[
Event::UserMessage {
turn: TurnId::new("t1"),
message_id: None,
principal: principal.clone(),
text: "Work unattended".into(),
attachments: vec![],
client_tools: vec![],
authorized_tools: vec![],
approval_mode: ApprovalMode::Headless,
},
Event::StepStarted {
step: 1,
control_through: dex_loop::Cursor::START,
},
Event::ModelStepCompleted {
step: 1,
text: String::new(),
calls: vec![question],
reasoning: None,
served: None,
timing: None,
},
Event::Question {
call: question_call.clone(),
text: "Send the message?".into(),
confirmation: Some(ActionConfirmation {
proposal_call_id: action.id.clone(),
tool: action.tool.clone(),
args_digest: dex_loop::args_digest(&action.args),
principal_id: principal.clone(),
}),
},
])
.await
.expect("persist parked question");
let engine = dex_loop::Engine::new(
log.clone(),
ScriptedModel::new(vec![vec![Ok(ModelChunk::Text("done".into()))]]),
LocalTools::new(workspace.path()),
LocalEffects::open(state_root.path().join("effects.json"))
.await
.expect("open ledger"),
Lexicon::default(),
Budget::default(),
);
assert_eq!(
drive_to_completion(
&engine,
&log,
&thread(),
&principal,
&CancellationToken::new()
)
.await
.expect("resume unattended question"),
Exit::Done
);
let events = read_log(&log).await.expect("read answered question");
let answers: Vec<_> = events
.iter()
.filter_map(|(_, event)| match event {
Event::Answer {
call,
principal,
text,
confirmation_decision,
args_digest,
} => Some((call, principal, text, confirmation_decision, args_digest)),
_ => None,
})
.collect();
assert_eq!(answers.len(), 1);
let (call, actor, text, decision, digest) = answers[0];
assert_eq!(call, &question_call);
assert_eq!(actor, &principal);
assert_eq!(text, UNATTENDED_ANSWER);
assert_eq!(*decision, ConfirmationDecision::Unspecified);
assert!(digest.is_empty());
let replayed = rehydrate(thread(), &events);
let mut attempted_action = action;
attempted_action.args["confirmation"] = serde_json::json!(attempted_action.id.as_str());
assert!(!replayed.confirmed_action(&attempted_action));
assert!(matches!(events.last(), Some((_, Event::Final { text })) if text == "done"));
}

#[tokio::test]
async fn drives_a_mutation_to_completion_with_no_approval() {
let state_root = TempDir::new().expect("state tempdir");
Expand Down
Loading
Loading