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
5 changes: 5 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"edit": {
"elicit_via_agent_relay": true
}
}
101 changes: 101 additions & 0 deletions crates/calm-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,31 @@ enum ReviewAction {
project_root: PathBuf,
review_id: String,
},
/// Same channel as the MCP tool `review_decide_via_agent_relay` -- see
/// `calm_core::config::EditConfig::elicit_via_agent_relay`'s doc comment
/// for the full tradeoff this accepts. Deliberately WEAKER than
/// `approve` (no TTY requirement): trusts the caller's own account of
/// what it showed a human and what they answered. Disabled unless
/// `[edit] elicit_via_agent_relay = true` in config.json. Requires
/// `--diff-digest` = the digest `calm review show <id>` prints
/// (`hash_content` of the review's CURRENT `diff_preview`) -- proves
/// the caller is referencing the real, current diff, not a guess or
/// stale copy.
ApproveViaAgentRelay {
#[arg(long, default_value = ".")]
project_root: PathBuf,
review_id: String,
#[arg(long)]
diff_digest: String,
},
/// Same as `approve-via-agent-relay` but declines instead.
DeclineViaAgentRelay {
#[arg(long, default_value = ".")]
project_root: PathBuf,
review_id: String,
#[arg(long)]
diff_digest: String,
},
}

#[tokio::main]
Expand Down Expand Up @@ -1301,6 +1326,16 @@ async fn main() -> Result<()> {
project_root,
review_id,
} => decide_review_interactively(&project_root, &review_id, false)?,
ReviewAction::ApproveViaAgentRelay {
project_root,
review_id,
diff_digest,
} => decide_review_via_agent_relay(&project_root, &review_id, &diff_digest, true)?,
ReviewAction::DeclineViaAgentRelay {
project_root,
review_id,
diff_digest,
} => decide_review_via_agent_relay(&project_root, &review_id, &diff_digest, false)?,
},
}

Expand Down Expand Up @@ -1331,6 +1366,11 @@ fn print_pending_review(r: &calm_core::authority::PendingReview) {
"{}",
calm_core::sanitize::sanitize_source_output(&r.diff_preview)
);
println!();
println!(
"diff_digest (for --diff-digest, e.g. with approve-via-agent-relay): {}",
calm_core::indexer::pipeline::hash_content(&r.diff_preview)
);
}

/// `calm review approve|decline` shared body. Requires a real interactive
Expand Down Expand Up @@ -1417,6 +1457,67 @@ fn decide_review_interactively(
Ok(())
}

/// `calm review approve-via-agent-relay|decline-via-agent-relay` shared body
/// -- the CLI mirror of the MCP tool `review_decide_via_agent_relay`, using
/// the exact same `calm_core::authority::decide_via_agent_relay` so the one
/// safety-relevant check (the diff digest match) lives in one place
/// regardless of which front-end calls it. See that function's doc comment,
/// and `EditConfig::elicit_via_agent_relay`'s, for the tradeoff this
/// deliberately accepts. No TTY requirement, unlike
/// `decide_review_interactively` above.
fn decide_review_via_agent_relay(
project_root: &std::path::Path,
review_id: &str,
diff_digest: &str,
approving: bool,
) -> Result<()> {
let root = std::fs::canonicalize(project_root)?;
let config = calm_core::config::load_config_or_warn(&root);
if !config.edit.elicit_via_agent_relay {
anyhow::bail!(
"this channel is disabled by default -- set [edit] elicit_via_agent_relay = true in \
config.json (repo root) or .calm/config.json to opt in (a deliberate, explicit \
project-owner decision -- see EditConfig::elicit_via_agent_relay's doc comment for \
the tradeoff). Prefer `calm review {}` in a real terminal if one is available.",
if approving { "approve" } else { "decline" }
);
}
let state_db_path = calm_server::default_state_db_path(&root);
let conn = calm_core::db::conn::open_state_writer(&state_db_path)?;
calm_core::db::schema::init_state_db_versioned(&conn)?;
match calm_core::authority::decide_via_agent_relay(&conn, review_id, diff_digest, approving)? {
calm_core::authority::AgentRelayOutcome::Decided(status) => {
println!("Review {review_id} {status} (via agent relay).");
if approving {
println!("The agent's retry of the same edit should now succeed.");
}
Ok(())
}
calm_core::authority::AgentRelayOutcome::NotFound => {
println!("No such review: {review_id}");
std::process::exit(1);
}
calm_core::authority::AgentRelayOutcome::AlreadyDecided(status) => {
println!("Review {review_id} is already {status}, not pending.");
std::process::exit(1);
}
calm_core::authority::AgentRelayOutcome::DigestMismatch => {
anyhow::bail!(
"diff_digest does not match this review's actual current diff_preview -- fetch \
it fresh (`calm review show {review_id}`) and pass THAT exact digest; do not \
guess or reuse a stale one"
);
}
calm_core::authority::AgentRelayOutcome::Race => {
println!(
"Could not decide {review_id} -- it may have expired or already been decided by \
someone else."
);
std::process::exit(1);
}
}
}

/// Builds the `{ "command", "args" }` MCP entry every client config shares,
/// so the absolute-binary form and the portable `npx` form differ only in
/// which `command`/`args` get passed in here.
Expand Down
6 changes: 3 additions & 3 deletions crates/calm-core/src/authority/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ pub mod review;
pub mod snapshot;

pub use pending_review::{
NewPendingReview, PENDING_REVIEW_DEFAULT_TTL_SECS, PendingReview, approve_pending_review,
decline_pending_review, find_approved_matching, get_pending_review, insert_pending_review,
list_pending_reviews,
AgentRelayOutcome, NewPendingReview, PENDING_REVIEW_DEFAULT_TTL_SECS, PendingReview,
approve_pending_review, decide_via_agent_relay, decline_pending_review, find_approved_matching,
get_pending_review, insert_pending_review, list_pending_reviews,
};
pub use receipt::{ApprovalReceipt, insert_approval_receipt};
pub use review::{
Expand Down
124 changes: 124 additions & 0 deletions crates/calm-core/src/authority/pending_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,68 @@ pub fn decline_pending_review(
decide_pending_review(conn, review_id, "declined", decided_by)
}

/// Outcome of `decide_via_agent_relay` -- one variant per case its two
/// front-ends (the MCP tool `review_decide_via_agent_relay` in calm-server,
/// and the CLI `calm review approve-via-agent-relay`/`decline-via-agent-relay`
/// in calm-cli) each already need to report back in their own idiom (a JSON
/// `ErrorDetail` for the former, an exit code/message for the latter).
#[derive(Debug, Clone, PartialEq)]
pub enum AgentRelayOutcome {
/// `"approved"` or `"declined"`.
Decided(&'static str),
NotFound,
/// Carries the review's actual current status (already decided, or
/// -- same row, same message -- expired).
AlreadyDecided(String),
DigestMismatch,
/// The review was decided or expired between the status check and the
/// write -- caller should re-fetch and retry.
Race,
}

/// Shared body of the "agent relay" decision channel: the deliberately
/// WEAKER, opt-in (`EditConfig::elicit_via_agent_relay`) sibling of the
/// TTY-gated `calm review approve`/`decline` (`decide_pending_review` above).
/// Both front-ends that expose this channel -- the MCP tool
/// `review_decide_via_agent_relay` and the CLI's `*-via-agent-relay`
/// subcommands -- call this exact function, so the one safety-relevant
/// check it performs (that `diff_digest` equals `hash_content` of the
/// review's own CURRENT `diff_preview`, proving the caller is referencing
/// the real, current diff and not a guess or stale copy) lives in exactly
/// one place rather than two copies that could drift. See
/// `EditConfig::elicit_via_agent_relay`'s doc comment for the full tradeoff
/// this channel accepts -- callers are responsible for the config-flag gate
/// and for not calling this before a human has actually seen the diff and
/// answered; this function itself cannot verify either.
pub fn decide_via_agent_relay(
conn: &Connection,
review_id: &str,
diff_digest: &str,
approve: bool,
) -> rusqlite::Result<AgentRelayOutcome> {
let Some(review) = get_pending_review(conn, review_id)? else {
return Ok(AgentRelayOutcome::NotFound);
};
if review.status != "pending" {
return Ok(AgentRelayOutcome::AlreadyDecided(review.status));
}
let expected_digest = crate::indexer::pipeline::hash_content(&review.diff_preview);
if diff_digest != expected_digest {
return Ok(AgentRelayOutcome::DigestMismatch);
}
let decided_by = "agent_relay_after_elicitation";
let ok = if approve {
approve_pending_review(conn, review_id, decided_by)?
} else {
decline_pending_review(conn, review_id, decided_by)?
};
Ok(if ok {
AgentRelayOutcome::Decided(if approve { "approved" } else { "declined" })
} else {
AgentRelayOutcome::Race
})
}

/// The retry-time lookup `edit_lines_impl_gated` uses: an unexpired,
/// `status = "approved"` row for this exact `path` + content `fingerprint`.
/// Content-addressed by construction (same rationale as
Expand Down Expand Up @@ -355,4 +417,66 @@ mod tests {
assert_eq!(all.len(), 2);
assert_eq!(all[0].review_id, second, "newest first");
}

#[test]
fn agent_relay_approves_on_matching_digest() {
let conn = state_conn();
let id =
insert_pending_review(&conn, &new_review("edit_lines", "a.py", "sha256:abc")).unwrap();
let review = get_pending_review(&conn, &id).unwrap().unwrap();
let digest = crate::indexer::pipeline::hash_content(&review.diff_preview);
let outcome = decide_via_agent_relay(&conn, &id, &digest, true).unwrap();
assert_eq!(outcome, AgentRelayOutcome::Decided("approved"));
let got = get_pending_review(&conn, &id).unwrap().unwrap();
assert_eq!(got.status, "approved");
assert_eq!(
got.decided_by.as_deref(),
Some("agent_relay_after_elicitation")
);
}

#[test]
fn agent_relay_declines_on_matching_digest() {
let conn = state_conn();
let id =
insert_pending_review(&conn, &new_review("edit_lines", "a.py", "sha256:abc")).unwrap();
let review = get_pending_review(&conn, &id).unwrap().unwrap();
let digest = crate::indexer::pipeline::hash_content(&review.diff_preview);
let outcome = decide_via_agent_relay(&conn, &id, &digest, false).unwrap();
assert_eq!(outcome, AgentRelayOutcome::Decided("declined"));
}

#[test]
fn agent_relay_refuses_a_stale_or_guessed_digest() {
let conn = state_conn();
let id =
insert_pending_review(&conn, &new_review("edit_lines", "a.py", "sha256:abc")).unwrap();
let outcome = decide_via_agent_relay(&conn, &id, "not-the-real-digest", true).unwrap();
assert_eq!(outcome, AgentRelayOutcome::DigestMismatch);
// Refused -- must not have flipped status.
let got = get_pending_review(&conn, &id).unwrap().unwrap();
assert_eq!(got.status, "pending");
}

#[test]
fn agent_relay_reports_not_found_for_unknown_id() {
let conn = state_conn();
let outcome = decide_via_agent_relay(&conn, "REVIEW-nope", "whatever", true).unwrap();
assert_eq!(outcome, AgentRelayOutcome::NotFound);
}

#[test]
fn agent_relay_reports_already_decided() {
let conn = state_conn();
let id =
insert_pending_review(&conn, &new_review("edit_lines", "a.py", "sha256:abc")).unwrap();
let review = get_pending_review(&conn, &id).unwrap().unwrap();
let digest = crate::indexer::pipeline::hash_content(&review.diff_preview);
decline_pending_review(&conn, &id, "cli_manual_review").unwrap();
let outcome = decide_via_agent_relay(&conn, &id, &digest, true).unwrap();
assert_eq!(
outcome,
AgentRelayOutcome::AlreadyDecided("declined".to_string())
);
}
}
Loading
Loading