use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{git_context, registry};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ClaimedContext {
pub agent: Option<String>,
pub agent_key_id: Option<String>,
pub worktree: Option<PathBuf>,
pub branch: Option<String>,
pub git_remote: Option<String>,
pub commit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VerifiedContext {
pub project: String,
pub agent: String,
pub agent_key_id: String,
pub worktree: PathBuf,
pub branch: String,
pub git_remote: String,
pub commit: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_common_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ContextProblem {
ContextRequired {
missing: Vec<&'static str>,
},
ContextMismatch {
field: &'static str,
claimed: String,
actual: String,
},
}
pub fn verify_no_prompt_context(
claimed: &ClaimedContext,
cwd: &Path,
resolved: ®istry::ResolvedProject,
agent_key_id: String,
) -> std::result::Result<VerifiedContext, ContextProblem> {
let mut missing = Vec::new();
if claimed.agent.as_deref().unwrap_or_default().is_empty() {
missing.push("agent");
}
if claimed.worktree.is_none() {
missing.push("worktree");
}
if claimed.branch.as_deref().unwrap_or_default().is_empty() {
missing.push("branch");
}
if claimed.git_remote.is_none() {
missing.push("gitRemote");
}
if claimed.commit.as_deref().unwrap_or_default().is_empty() {
missing.push("commit");
}
if !missing.is_empty() {
return Err(ContextProblem::ContextRequired { missing });
}
let agent = claimed.agent.clone().unwrap_or_default();
let claimed_worktree = claimed.worktree.as_ref().expect("checked above");
let worktree = canonicalize_or_problem("worktree", claimed_worktree)?;
let cwd = canonicalize_or_problem("cwd", cwd)?;
if !cwd.starts_with(&worktree) {
return Err(ContextProblem::ContextMismatch {
field: "worktree",
claimed: worktree.display().to_string(),
actual: cwd.display().to_string(),
});
}
let git = git_context::collect_git_context(&worktree);
let actual_worktree = git
.worktree_path
.as_deref()
.map(PathBuf::from)
.and_then(|path| path.canonicalize().ok())
.unwrap_or_else(|| worktree.clone());
if actual_worktree != worktree {
return Err(ContextProblem::ContextMismatch {
field: "worktree",
claimed: worktree.display().to_string(),
actual: actual_worktree.display().to_string(),
});
}
require_match("branch", claimed.branch.as_deref(), git.branch.as_deref())?;
require_remote_match(claimed.git_remote.as_deref(), git.remote.as_deref())?;
require_match("commit", claimed.commit.as_deref(), git.commit.as_deref())?;
Ok(VerifiedContext {
project: resolved.name.clone(),
agent,
agent_key_id,
worktree,
branch: claimed.branch.clone().unwrap_or_default(),
git_remote: normalize_remote(claimed.git_remote.as_deref().unwrap_or_default()),
commit: claimed.commit.clone().unwrap_or_default(),
git_common_dir: git.common_dir,
})
}
pub fn normalize_remote(remote: &str) -> String {
let mut value = remote.trim().trim_end_matches('/').to_string();
if let Some(stripped) = value.strip_suffix(".git") {
value = stripped.to_string();
}
value
}
fn canonicalize_or_problem(
field: &'static str,
path: &Path,
) -> std::result::Result<PathBuf, ContextProblem> {
path.canonicalize()
.map_err(|error| ContextProblem::ContextMismatch {
field,
claimed: path.display().to_string(),
actual: error.to_string(),
})
}
fn require_match(
field: &'static str,
claimed: Option<&str>,
actual: Option<&str>,
) -> std::result::Result<(), ContextProblem> {
let claimed = claimed.unwrap_or_default();
let actual = actual.unwrap_or_default();
if claimed == actual {
Ok(())
} else {
Err(ContextProblem::ContextMismatch {
field,
claimed: claimed.to_string(),
actual: actual.to_string(),
})
}
}
fn require_remote_match(
claimed: Option<&str>,
actual: Option<&str>,
) -> std::result::Result<(), ContextProblem> {
let claimed = normalize_remote(claimed.unwrap_or_default());
let actual = normalize_remote(actual.unwrap_or_default());
if claimed == actual {
Ok(())
} else {
Err(ContextProblem::ContextMismatch {
field: "gitRemote",
claimed,
actual,
})
}
}
pub fn context_problem_json(problem: &ContextProblem) -> Result<String> {
match problem {
ContextProblem::ContextRequired { .. } => {
serde_json::to_string_pretty(problem).context("failed to serialize context problem")
}
ContextProblem::ContextMismatch {
field,
claimed,
actual,
} => {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RedactedMismatch<'a> {
status: &'static str,
field: &'a str,
claimed: &'a str,
actual_present: bool,
actual_hash: String,
}
let actual_hash = hex::encode(Sha256::digest(actual.as_bytes()));
let response = RedactedMismatch {
status: "context_mismatch",
field,
claimed,
actual_present: !actual.is_empty(),
actual_hash,
};
serde_json::to_string_pretty(&response).context("failed to serialize context problem")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "tester@example.test"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Tester"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("README.md"), "demo").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.env("GIT_AUTHOR_NAME", "Tester")
.env("GIT_AUTHOR_EMAIL", "tester@example.test")
.env("GIT_COMMITTER_NAME", "Tester")
.env("GIT_COMMITTER_EMAIL", "tester@example.test")
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["remote", "add", "origin", "https://example.test/repo.git"])
.current_dir(dir.path())
.output()
.unwrap();
dir
}
#[test]
fn normalize_remote_ignores_git_suffix_and_trailing_slash() {
assert_eq!(
normalize_remote("https://example.test/repo.git/"),
"https://example.test/repo"
);
}
#[test]
fn verifies_matching_context_and_rejects_mismatches() {
let repo = git_repo();
let git = git_context::collect_git_context(repo.path());
let resolved = registry::ResolvedProject {
name: "demo".to_string(),
path: repo.path().to_path_buf(),
vault: repo.path().join(".env.vault"),
};
let claimed = ClaimedContext {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: Some(repo.path().to_path_buf()),
branch: git.branch.clone(),
git_remote: git.remote.clone(),
commit: git.commit.clone(),
};
let verified =
verify_no_prompt_context(&claimed, repo.path(), &resolved, "key".to_string()).unwrap();
assert_eq!(verified.project, "demo");
let mut bad = claimed.clone();
let wrong_branch = format!("not-{}", git.branch.as_deref().unwrap_or("main"));
bad.branch = Some(wrong_branch);
assert!(matches!(
verify_no_prompt_context(&bad, repo.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "branch",
..
})
));
let outside = tempfile::tempdir().unwrap();
assert!(matches!(
verify_no_prompt_context(&claimed, outside.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "worktree",
..
})
));
let subdir = repo.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let mut subdir_claim = claimed.clone();
subdir_claim.worktree = Some(subdir.clone());
assert!(matches!(
verify_no_prompt_context(&subdir_claim, &subdir, &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "worktree",
..
})
));
let mut missing_path = claimed.clone();
missing_path.worktree = Some(repo.path().join("missing"));
assert!(matches!(
verify_no_prompt_context(&missing_path, repo.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "worktree",
..
})
));
let mut bad_remote = claimed.clone();
bad_remote.git_remote = Some("https://example.test/other.git".to_string());
assert!(matches!(
verify_no_prompt_context(&bad_remote, repo.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "gitRemote",
..
})
));
let missing = ClaimedContext {
agent: None,
agent_key_id: None,
worktree: None,
branch: None,
git_remote: None,
commit: None,
};
assert!(matches!(
verify_no_prompt_context(&missing, repo.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextRequired { .. })
));
}
#[test]
fn verifies_explicit_empty_remote_and_redacts_mismatch_json() {
let repo = git_repo();
Command::new("git")
.args(["remote", "remove", "origin"])
.current_dir(repo.path())
.output()
.unwrap();
let git = git_context::collect_git_context(repo.path());
let resolved = registry::ResolvedProject {
name: "demo".to_string(),
path: repo.path().to_path_buf(),
vault: repo.path().join(".env.vault"),
};
let claimed = ClaimedContext {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: Some(repo.path().to_path_buf()),
branch: git.branch.clone(),
git_remote: Some(String::new()),
commit: git.commit.clone(),
};
let verified =
verify_no_prompt_context(&claimed, repo.path(), &resolved, "key".to_string()).unwrap();
assert_eq!(verified.git_remote, "");
let missing_remote = ClaimedContext {
git_remote: None,
..claimed.clone()
};
assert!(matches!(
verify_no_prompt_context(&missing_remote, repo.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextRequired { missing }) if missing == vec!["gitRemote"]
));
let mut wrong_remote = claimed;
wrong_remote.git_remote = Some("https://example.test/wrong.git".to_string());
let problem =
verify_no_prompt_context(&wrong_remote, repo.path(), &resolved, "key".to_string())
.unwrap_err();
let json = context_problem_json(&problem).unwrap();
assert!(json.contains("\"actualPresent\": false"));
assert!(json.contains("\"actualHash\""));
assert!(!json.contains("\"actual\":"));
}
#[test]
fn context_verification_reports_canonicalization_and_git_fallback_edges() {
let cwd = tempfile::tempdir().unwrap();
let resolved = registry::ResolvedProject {
name: "demo".to_string(),
path: cwd.path().to_path_buf(),
vault: cwd.path().join(".env.vault"),
};
let missing_worktree = cwd.path().join("missing");
let missing_claim = ClaimedContext {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: Some(missing_worktree),
branch: Some("main".to_string()),
git_remote: Some("https://example.test/repo.git".to_string()),
commit: Some("abc".to_string()),
};
assert!(matches!(
verify_no_prompt_context(&missing_claim, cwd.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "worktree",
..
})
));
let no_git_claim = ClaimedContext {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: Some(cwd.path().to_path_buf()),
branch: Some("main".to_string()),
git_remote: Some("https://example.test/repo.git".to_string()),
commit: Some("abc".to_string()),
};
assert!(matches!(
verify_no_prompt_context(&no_git_claim, cwd.path(), &resolved, "key".to_string()),
Err(ContextProblem::ContextMismatch {
field: "branch",
..
})
));
}
}