use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::SystemTime;
use regex::Regex;
use crate::broker::learnings::{CATEGORY_PERMISSION_PATTERN, LearningRecord};
use crate::broker::messages::{ArtifactPayload, BrokerMessage, FeedbackPayload};
use crate::broker::{BrokerState, delivery};
use crate::config::RoleGatingMode;
pub const SUPERVISOR_AGENT_ID: &str = "supervisor";
pub const ROLE_GUARD_SENDER: &str = "opsx-role-gating";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Classification {
Archive {
reason: String,
},
NotArchive,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommitDiff {
pub touched_paths: Vec<String>,
}
impl CommitDiff {
#[must_use]
pub fn from_paths(paths: &[String]) -> Self {
Self {
touched_paths: paths.to_vec(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentAttribution {
Supervisor,
Coding(String),
Unknown,
}
impl AgentAttribution {
#[must_use]
pub fn is_violation(&self) -> bool {
!matches!(self, AgentAttribution::Supervisor)
}
}
#[derive(Debug, Clone)]
pub struct RoleGatingContext {
pub mode: RoleGatingMode,
pub engine_is_openspec: bool,
pub roster: Vec<(String, PathBuf)>,
}
impl RoleGatingContext {
#[must_use]
pub fn worktree_for(&self, agent_id: &str) -> Option<&Path> {
self.roster
.iter()
.find(|(id, _)| id == agent_id)
.map(|(_, p)| p.as_path())
}
#[must_use]
pub fn is_active(&self) -> bool {
self.engine_is_openspec && self.mode != RoleGatingMode::Off
}
}
fn archive_message_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"^chore\(specs\): archive [a-z0-9-]+; sync deltas to main specs$")
.expect("archive message regex compiles")
})
}
fn archive_move_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"openspec/changes/archive/[^/]+/").expect("archive move regex compiles")
})
}
fn spec_addition_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"openspec/specs/[^/]+/spec\.md$").expect("spec addition regex compiles")
})
}
#[must_use]
pub fn touches_openspec(path: &str) -> bool {
path.contains("openspec/changes/") || path.contains("openspec/specs/")
}
#[must_use]
pub fn classify_commit(commit_message: &str, diff: &CommitDiff) -> Classification {
let mut reasons: Vec<String> = Vec::new();
let subject = commit_message.lines().next().unwrap_or("").trim();
if !subject.is_empty() && archive_message_re().is_match(subject) {
reasons.push(format!(
"commit message matched the archive heuristic (\"{subject}\")"
));
}
if let Some(path) = diff
.touched_paths
.iter()
.find(|p| archive_move_re().is_match(p))
{
reasons.push(format!(
"diff moved files into openspec/changes/archive/ ({path})"
));
}
if let Some(path) = diff
.touched_paths
.iter()
.find(|p| spec_addition_re().is_match(p))
{
reasons.push(format!("diff added/updated a main spec ({path})"));
}
if reasons.is_empty() {
Classification::NotArchive
} else {
Classification::Archive {
reason: reasons.join("; "),
}
}
}
#[must_use]
pub fn resolve_agent_id(worktree: &Path, roster: &[(String, PathBuf)]) -> AgentAttribution {
for (agent_id, path) in roster {
if paths_match(worktree, path) {
return if agent_id == SUPERVISOR_AGENT_ID {
AgentAttribution::Supervisor
} else {
AgentAttribution::Coding(agent_id.clone())
};
}
}
AgentAttribution::Unknown
}
fn paths_match(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
matches!((a.canonicalize(), b.canonicalize()), (Ok(ca), Ok(cb)) if ca == cb)
}
#[must_use]
pub fn warning_text(short_sha: &str, agent_id: &str, reason: &str) -> String {
format!(
"opsx-role-gating: detected archive activity on commit {short_sha} by agent {agent_id} \
(not the supervisor).\n Reason: {reason}.\n `/opsx:verify` and `/opsx:archive` are \
supervisor-only — the supervisor verifies and archives changes after merge. Do not run \
them (or `openspec archive`) from a coding-agent worktree."
)
}
#[must_use]
pub fn revert_request_text(short_sha: &str, agent_id: &str, reason: &str) -> String {
format!(
"opsx-role-gating (block mode): coding agent {agent_id} committed an OpenSpec archive \
({short_sha}) — this is supervisor-only. Per your merge-orchestration revert flow, \
confirm with the user (unless `[supervisor] auto_revert = true`), then run \
`git revert {short_sha}` and send the agent an `agent.feedback` explaining the revert. \
Trigger: {reason}."
)
}
pub fn run_guard(
state: &Arc<BrokerState>,
agent_id: &str,
payload: &ArtifactPayload,
ctx: &RoleGatingContext,
) {
if !ctx.is_active() {
return;
}
if !payload.modified_files.iter().any(|p| touches_openspec(p)) {
return;
}
let worktree = ctx.worktree_for(agent_id).map(Path::to_path_buf);
let (short_sha, message) = worktree
.as_deref()
.and_then(head_commit_info)
.unwrap_or_else(|| ("unknown".to_string(), String::new()));
let diff = CommitDiff::from_paths(&payload.modified_files);
let reason = match classify_commit(&message, &diff) {
Classification::Archive { reason } => reason,
Classification::NotArchive => return,
};
let attribution = match worktree.as_deref() {
Some(wt) => resolve_agent_id(wt, &ctx.roster),
None => AgentAttribution::Unknown,
};
if !attribution.is_violation() {
return;
}
publish_warn(state, agent_id, &short_sha, &reason);
if ctx.mode == RoleGatingMode::Block {
let revert = revert_request_text(&short_sha, agent_id, &reason);
delivery::publish_message(
state,
&BrokerMessage::Feedback {
agent_id: SUPERVISOR_AGENT_ID.to_string(),
payload: FeedbackPayload {
from: ROLE_GUARD_SENDER.to_string(),
errors: vec![revert],
},
},
);
}
}
fn publish_warn(state: &Arc<BrokerState>, violator: &str, short_sha: &str, reason: &str) {
let warning = warning_text(short_sha, violator, reason);
delivery::publish_message(
state,
&BrokerMessage::Feedback {
agent_id: violator.to_string(),
payload: FeedbackPayload {
from: ROLE_GUARD_SENDER.to_string(),
errors: vec![warning.clone()],
},
},
);
let record = LearningRecord {
category: CATEGORY_PERMISSION_PATTERN.to_string(),
agent_id: violator.to_string(),
branch_id: None,
title: format!("opsx role-gating violation: {violator} ran an archive ({short_sha})"),
body: serde_json::json!({
"rule": "opsx-role-gating",
"agent_id": violator,
"commit": short_sha,
"reason": reason,
}),
timestamp: SystemTime::now(),
};
delivery::publish_message(state, &BrokerMessage::from(&record));
}
fn head_commit_info(worktree: &Path) -> Option<(String, String)> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(worktree)
.args(["log", "-1", "--pretty=format:%h%n%B"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let mut parts = text.splitn(2, '\n');
let short_sha = parts.next()?.trim().to_string();
let message = parts.next().unwrap_or("").to_string();
Some((short_sha, message))
}
#[cfg(test)]
mod tests {
use super::*;
fn diff(paths: &[&str]) -> CommitDiff {
CommitDiff {
touched_paths: paths.iter().map(|s| (*s).to_string()).collect(),
}
}
#[test]
fn canonical_archive_message_is_classified() {
let result = classify_commit(
"chore(specs): archive opsx-role-gating; sync deltas to main specs",
&CommitDiff::default(),
);
match result {
Classification::Archive { reason } => {
assert!(reason.contains("commit message matched"), "got: {reason}");
assert!(reason.contains("opsx-role-gating"), "got: {reason}");
}
Classification::NotArchive => panic!("expected Archive"),
}
}
#[test]
fn archive_message_with_body_matches_on_subject_line() {
let msg =
"chore(specs): archive add-auth; sync deltas to main specs\n\nLonger body text here.";
assert!(matches!(
classify_commit(msg, &CommitDiff::default()),
Classification::Archive { .. }
));
}
#[test]
fn non_canonical_message_with_archive_move_diff_is_classified() {
let result = classify_commit(
"chore: tidy up changes",
&diff(&[
"openspec/changes/feat-x/proposal.md",
"openspec/changes/archive/feat-x/proposal.md",
]),
);
match result {
Classification::Archive { reason } => {
assert!(
reason.contains("openspec/changes/archive/"),
"got: {reason}"
);
assert!(!reason.contains("commit message matched"), "got: {reason}");
}
Classification::NotArchive => panic!("expected Archive via diff shape"),
}
}
#[test]
fn spec_addition_diff_is_classified() {
let result = classify_commit(
"docs: sync",
&diff(&["openspec/specs/some-capability/spec.md"]),
);
match result {
Classification::Archive { reason } => {
assert!(reason.contains("main spec"), "got: {reason}");
assert!(reason.contains("some-capability/spec.md"), "got: {reason}");
}
Classification::NotArchive => panic!("expected Archive via spec addition"),
}
}
#[test]
fn both_signals_combine_into_one_reason() {
let result = classify_commit(
"chore(specs): archive feat-x; sync deltas to main specs",
&diff(&[
"openspec/changes/archive/feat-x/tasks.md",
"openspec/specs/feat-x/spec.md",
]),
);
match result {
Classification::Archive { reason } => {
assert!(reason.contains("commit message matched"), "got: {reason}");
assert!(
reason.contains("openspec/changes/archive/"),
"got: {reason}"
);
assert!(reason.contains("main spec"), "got: {reason}");
assert!(
reason.contains(';'),
"combined reason joins signals: {reason}"
);
}
Classification::NotArchive => panic!("expected Archive"),
}
}
#[test]
fn neither_signal_is_not_archive() {
let result = classify_commit(
"feat(broker): add a new endpoint",
&diff(&["src/broker/server.rs", "openspec/changes/feat-x/tasks.md"]),
);
assert_eq!(result, Classification::NotArchive);
}
#[test]
fn archive_word_in_a_normal_message_does_not_match() {
let result = classify_commit(
"feat: archive old logs to cold storage",
&CommitDiff::default(),
);
assert_eq!(result, Classification::NotArchive);
}
fn roster() -> Vec<(String, PathBuf)> {
vec![
("supervisor".to_string(), PathBuf::from("/repo")),
(
"feat-x".to_string(),
PathBuf::from("/repo/.worktrees/feat-x"),
),
(
"feat-y".to_string(),
PathBuf::from("/repo/.worktrees/feat-y"),
),
]
}
#[test]
fn resolve_supervisor_worktree() {
let r = roster();
assert_eq!(
resolve_agent_id(Path::new("/repo"), &r),
AgentAttribution::Supervisor
);
assert!(!AgentAttribution::Supervisor.is_violation());
}
#[test]
fn resolve_coding_worktree() {
let r = roster();
assert_eq!(
resolve_agent_id(Path::new("/repo/.worktrees/feat-x"), &r),
AgentAttribution::Coding("feat-x".to_string())
);
assert!(AgentAttribution::Coding("feat-x".to_string()).is_violation());
}
#[test]
fn resolve_unknown_worktree_is_violation() {
let r = roster();
assert_eq!(
resolve_agent_id(Path::new("/somewhere/else"), &r),
AgentAttribution::Unknown
);
assert!(AgentAttribution::Unknown.is_violation());
}
#[test]
fn warning_text_names_sha_agent_and_reason() {
let text = warning_text(
"abc1234",
"feat-x",
"commit message matched the archive heuristic (\"chore(specs): archive feat-x; sync deltas to main specs\")",
);
assert!(text.contains("abc1234"));
assert!(text.contains("feat-x"));
assert!(text.contains("commit message matched"));
assert!(text.contains("/opsx:archive"));
}
#[test]
fn revert_request_text_addresses_supervisor_revert_flow() {
let text = revert_request_text(
"abc1234",
"feat-x",
"diff moved files into openspec/changes/archive/ (x)",
);
assert!(text.contains("git revert abc1234"));
assert!(text.contains("auto_revert"));
assert!(text.contains("feat-x"));
}
#[test]
fn context_inactive_when_off_or_non_openspec() {
let base = RoleGatingContext {
mode: RoleGatingMode::Warn,
engine_is_openspec: true,
roster: vec![],
};
assert!(base.is_active());
assert!(
!RoleGatingContext {
mode: RoleGatingMode::Off,
..base.clone()
}
.is_active()
);
assert!(
!RoleGatingContext {
engine_is_openspec: false,
..base
}
.is_active()
);
}
#[test]
fn touches_openspec_pre_filter() {
assert!(touches_openspec("openspec/changes/feat-x/tasks.md"));
assert!(touches_openspec("openspec/specs/cap/spec.md"));
assert!(!touches_openspec("src/main.rs"));
}
}