1use std::path::{Path, PathBuf};
21use std::sync::{Arc, OnceLock};
22use std::time::SystemTime;
23
24use regex::Regex;
25
26use crate::broker::learnings::{CATEGORY_PERMISSION_PATTERN, LearningRecord};
27use crate::broker::messages::{ArtifactPayload, BrokerMessage, FeedbackPayload};
28use crate::broker::{BrokerState, delivery};
29use crate::config::RoleGatingMode;
30
31pub const SUPERVISOR_AGENT_ID: &str = "supervisor";
35
36pub const ROLE_GUARD_SENDER: &str = "opsx-role-gating";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Classification {
43 Archive {
46 reason: String,
49 },
50 NotArchive,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct CommitDiff {
61 pub touched_paths: Vec<String>,
63}
64
65impl CommitDiff {
66 #[must_use]
68 pub fn from_paths(paths: &[String]) -> Self {
69 Self {
70 touched_paths: paths.to_vec(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum AgentAttribution {
78 Supervisor,
80 Coding(String),
82 Unknown,
85}
86
87impl AgentAttribution {
88 #[must_use]
93 pub fn is_violation(&self) -> bool {
94 !matches!(self, AgentAttribution::Supervisor)
95 }
96}
97
98#[derive(Debug, Clone)]
104pub struct RoleGatingContext {
105 pub mode: RoleGatingMode,
107 pub engine_is_openspec: bool,
110 pub roster: Vec<(String, PathBuf)>,
113}
114
115impl RoleGatingContext {
116 #[must_use]
118 pub fn worktree_for(&self, agent_id: &str) -> Option<&Path> {
119 self.roster
120 .iter()
121 .find(|(id, _)| id == agent_id)
122 .map(|(_, p)| p.as_path())
123 }
124
125 #[must_use]
127 pub fn is_active(&self) -> bool {
128 self.engine_is_openspec && self.mode != RoleGatingMode::Off
129 }
130}
131
132fn archive_message_re() -> &'static Regex {
135 static RE: OnceLock<Regex> = OnceLock::new();
136 RE.get_or_init(|| {
137 Regex::new(r"^chore\(specs\): archive [a-z0-9-]+; sync deltas to main specs$")
138 .expect("archive message regex compiles")
139 })
140}
141
142fn archive_move_re() -> &'static Regex {
145 static RE: OnceLock<Regex> = OnceLock::new();
146 RE.get_or_init(|| {
147 Regex::new(r"openspec/changes/archive/[^/]+/").expect("archive move regex compiles")
148 })
149}
150
151fn spec_addition_re() -> &'static Regex {
154 static RE: OnceLock<Regex> = OnceLock::new();
155 RE.get_or_init(|| {
156 Regex::new(r"openspec/specs/[^/]+/spec\.md$").expect("spec addition regex compiles")
157 })
158}
159
160#[must_use]
163pub fn touches_openspec(path: &str) -> bool {
164 path.contains("openspec/changes/") || path.contains("openspec/specs/")
165}
166
167#[must_use]
176pub fn classify_commit(commit_message: &str, diff: &CommitDiff) -> Classification {
177 let mut reasons: Vec<String> = Vec::new();
178
179 let subject = commit_message.lines().next().unwrap_or("").trim();
180 if !subject.is_empty() && archive_message_re().is_match(subject) {
181 reasons.push(format!(
182 "commit message matched the archive heuristic (\"{subject}\")"
183 ));
184 }
185
186 if let Some(path) = diff
187 .touched_paths
188 .iter()
189 .find(|p| archive_move_re().is_match(p))
190 {
191 reasons.push(format!(
192 "diff moved files into openspec/changes/archive/ ({path})"
193 ));
194 }
195
196 if let Some(path) = diff
197 .touched_paths
198 .iter()
199 .find(|p| spec_addition_re().is_match(p))
200 {
201 reasons.push(format!("diff added/updated a main spec ({path})"));
202 }
203
204 if reasons.is_empty() {
205 Classification::NotArchive
206 } else {
207 Classification::Archive {
208 reason: reasons.join("; "),
209 }
210 }
211}
212
213#[must_use]
220pub fn resolve_agent_id(worktree: &Path, roster: &[(String, PathBuf)]) -> AgentAttribution {
221 for (agent_id, path) in roster {
222 if paths_match(worktree, path) {
223 return if agent_id == SUPERVISOR_AGENT_ID {
224 AgentAttribution::Supervisor
225 } else {
226 AgentAttribution::Coding(agent_id.clone())
227 };
228 }
229 }
230 AgentAttribution::Unknown
231}
232
233fn paths_match(a: &Path, b: &Path) -> bool {
237 if a == b {
238 return true;
239 }
240 matches!((a.canonicalize(), b.canonicalize()), (Ok(ca), Ok(cb)) if ca == cb)
241}
242
243#[must_use]
246pub fn warning_text(short_sha: &str, agent_id: &str, reason: &str) -> String {
247 format!(
248 "opsx-role-gating: detected archive activity on commit {short_sha} by agent {agent_id} \
249 (not the supervisor).\n Reason: {reason}.\n `/opsx:verify` and `/opsx:archive` are \
250 supervisor-only — the supervisor verifies and archives changes after merge. Do not run \
251 them (or `openspec archive`) from a coding-agent worktree."
252 )
253}
254
255#[must_use]
257pub fn revert_request_text(short_sha: &str, agent_id: &str, reason: &str) -> String {
258 format!(
259 "opsx-role-gating (block mode): coding agent {agent_id} committed an OpenSpec archive \
260 ({short_sha}) — this is supervisor-only. Per your merge-orchestration revert flow, \
261 confirm with the user (unless `[supervisor] auto_revert = true`), then run \
262 `git revert {short_sha}` and send the agent an `agent.feedback` explaining the revert. \
263 Trigger: {reason}."
264 )
265}
266
267pub fn run_guard(
275 state: &Arc<BrokerState>,
276 agent_id: &str,
277 payload: &ArtifactPayload,
278 ctx: &RoleGatingContext,
279) {
280 if !ctx.is_active() {
281 return;
282 }
283
284 if !payload.modified_files.iter().any(|p| touches_openspec(p)) {
286 return;
287 }
288
289 let worktree = ctx.worktree_for(agent_id).map(Path::to_path_buf);
293 let (short_sha, message) = worktree
294 .as_deref()
295 .and_then(head_commit_info)
296 .unwrap_or_else(|| ("unknown".to_string(), String::new()));
297
298 let diff = CommitDiff::from_paths(&payload.modified_files);
299 let reason = match classify_commit(&message, &diff) {
300 Classification::Archive { reason } => reason,
301 Classification::NotArchive => return,
302 };
303
304 let attribution = match worktree.as_deref() {
305 Some(wt) => resolve_agent_id(wt, &ctx.roster),
306 None => AgentAttribution::Unknown,
307 };
308 if !attribution.is_violation() {
309 return;
311 }
312
313 publish_warn(state, agent_id, &short_sha, &reason);
316
317 if ctx.mode == RoleGatingMode::Block {
319 let revert = revert_request_text(&short_sha, agent_id, &reason);
320 delivery::publish_message(
321 state,
322 &BrokerMessage::Feedback {
323 agent_id: SUPERVISOR_AGENT_ID.to_string(),
324 payload: FeedbackPayload {
325 from: ROLE_GUARD_SENDER.to_string(),
326 errors: vec![revert],
327 },
328 },
329 );
330 }
331}
332
333fn publish_warn(state: &Arc<BrokerState>, violator: &str, short_sha: &str, reason: &str) {
336 let warning = warning_text(short_sha, violator, reason);
337 delivery::publish_message(
338 state,
339 &BrokerMessage::Feedback {
340 agent_id: violator.to_string(),
341 payload: FeedbackPayload {
342 from: ROLE_GUARD_SENDER.to_string(),
343 errors: vec![warning.clone()],
344 },
345 },
346 );
347
348 let record = LearningRecord {
349 category: CATEGORY_PERMISSION_PATTERN.to_string(),
350 agent_id: violator.to_string(),
351 branch_id: None,
354 title: format!("opsx role-gating violation: {violator} ran an archive ({short_sha})"),
355 body: serde_json::json!({
356 "rule": "opsx-role-gating",
357 "agent_id": violator,
358 "commit": short_sha,
359 "reason": reason,
360 }),
361 timestamp: SystemTime::now(),
362 };
363 delivery::publish_message(state, &BrokerMessage::from(&record));
364}
365
366fn head_commit_info(worktree: &Path) -> Option<(String, String)> {
370 let output = std::process::Command::new("git")
371 .arg("-C")
372 .arg(worktree)
373 .args(["log", "-1", "--pretty=format:%h%n%B"])
374 .output()
375 .ok()?;
376 if !output.status.success() {
377 return None;
378 }
379 let text = String::from_utf8_lossy(&output.stdout);
380 let mut parts = text.splitn(2, '\n');
381 let short_sha = parts.next()?.trim().to_string();
382 let message = parts.next().unwrap_or("").to_string();
383 Some((short_sha, message))
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn diff(paths: &[&str]) -> CommitDiff {
391 CommitDiff {
392 touched_paths: paths.iter().map(|s| (*s).to_string()).collect(),
393 }
394 }
395
396 #[test]
399 fn canonical_archive_message_is_classified() {
400 let result = classify_commit(
401 "chore(specs): archive opsx-role-gating; sync deltas to main specs",
402 &CommitDiff::default(),
403 );
404 match result {
405 Classification::Archive { reason } => {
406 assert!(reason.contains("commit message matched"), "got: {reason}");
407 assert!(reason.contains("opsx-role-gating"), "got: {reason}");
408 }
409 Classification::NotArchive => panic!("expected Archive"),
410 }
411 }
412
413 #[test]
414 fn archive_message_with_body_matches_on_subject_line() {
415 let msg =
416 "chore(specs): archive add-auth; sync deltas to main specs\n\nLonger body text here.";
417 assert!(matches!(
418 classify_commit(msg, &CommitDiff::default()),
419 Classification::Archive { .. }
420 ));
421 }
422
423 #[test]
424 fn non_canonical_message_with_archive_move_diff_is_classified() {
425 let result = classify_commit(
426 "chore: tidy up changes",
427 &diff(&[
428 "openspec/changes/feat-x/proposal.md",
429 "openspec/changes/archive/feat-x/proposal.md",
430 ]),
431 );
432 match result {
433 Classification::Archive { reason } => {
434 assert!(
435 reason.contains("openspec/changes/archive/"),
436 "got: {reason}"
437 );
438 assert!(!reason.contains("commit message matched"), "got: {reason}");
439 }
440 Classification::NotArchive => panic!("expected Archive via diff shape"),
441 }
442 }
443
444 #[test]
445 fn spec_addition_diff_is_classified() {
446 let result = classify_commit(
447 "docs: sync",
448 &diff(&["openspec/specs/some-capability/spec.md"]),
449 );
450 match result {
451 Classification::Archive { reason } => {
452 assert!(reason.contains("main spec"), "got: {reason}");
453 assert!(reason.contains("some-capability/spec.md"), "got: {reason}");
454 }
455 Classification::NotArchive => panic!("expected Archive via spec addition"),
456 }
457 }
458
459 #[test]
460 fn both_signals_combine_into_one_reason() {
461 let result = classify_commit(
462 "chore(specs): archive feat-x; sync deltas to main specs",
463 &diff(&[
464 "openspec/changes/archive/feat-x/tasks.md",
465 "openspec/specs/feat-x/spec.md",
466 ]),
467 );
468 match result {
469 Classification::Archive { reason } => {
470 assert!(reason.contains("commit message matched"), "got: {reason}");
471 assert!(
472 reason.contains("openspec/changes/archive/"),
473 "got: {reason}"
474 );
475 assert!(reason.contains("main spec"), "got: {reason}");
476 assert!(
477 reason.contains(';'),
478 "combined reason joins signals: {reason}"
479 );
480 }
481 Classification::NotArchive => panic!("expected Archive"),
482 }
483 }
484
485 #[test]
486 fn neither_signal_is_not_archive() {
487 let result = classify_commit(
488 "feat(broker): add a new endpoint",
489 &diff(&["src/broker/server.rs", "openspec/changes/feat-x/tasks.md"]),
490 );
491 assert_eq!(result, Classification::NotArchive);
492 }
493
494 #[test]
495 fn archive_word_in_a_normal_message_does_not_match() {
496 let result = classify_commit(
499 "feat: archive old logs to cold storage",
500 &CommitDiff::default(),
501 );
502 assert_eq!(result, Classification::NotArchive);
503 }
504
505 fn roster() -> Vec<(String, PathBuf)> {
508 vec![
509 ("supervisor".to_string(), PathBuf::from("/repo")),
510 (
511 "feat-x".to_string(),
512 PathBuf::from("/repo/.worktrees/feat-x"),
513 ),
514 (
515 "feat-y".to_string(),
516 PathBuf::from("/repo/.worktrees/feat-y"),
517 ),
518 ]
519 }
520
521 #[test]
522 fn resolve_supervisor_worktree() {
523 let r = roster();
524 assert_eq!(
525 resolve_agent_id(Path::new("/repo"), &r),
526 AgentAttribution::Supervisor
527 );
528 assert!(!AgentAttribution::Supervisor.is_violation());
529 }
530
531 #[test]
532 fn resolve_coding_worktree() {
533 let r = roster();
534 assert_eq!(
535 resolve_agent_id(Path::new("/repo/.worktrees/feat-x"), &r),
536 AgentAttribution::Coding("feat-x".to_string())
537 );
538 assert!(AgentAttribution::Coding("feat-x".to_string()).is_violation());
539 }
540
541 #[test]
542 fn resolve_unknown_worktree_is_violation() {
543 let r = roster();
544 assert_eq!(
545 resolve_agent_id(Path::new("/somewhere/else"), &r),
546 AgentAttribution::Unknown
547 );
548 assert!(AgentAttribution::Unknown.is_violation());
549 }
550
551 #[test]
554 fn warning_text_names_sha_agent_and_reason() {
555 let text = warning_text(
556 "abc1234",
557 "feat-x",
558 "commit message matched the archive heuristic (\"chore(specs): archive feat-x; sync deltas to main specs\")",
559 );
560 assert!(text.contains("abc1234"));
561 assert!(text.contains("feat-x"));
562 assert!(text.contains("commit message matched"));
563 assert!(text.contains("/opsx:archive"));
564 }
565
566 #[test]
567 fn revert_request_text_addresses_supervisor_revert_flow() {
568 let text = revert_request_text(
569 "abc1234",
570 "feat-x",
571 "diff moved files into openspec/changes/archive/ (x)",
572 );
573 assert!(text.contains("git revert abc1234"));
574 assert!(text.contains("auto_revert"));
575 assert!(text.contains("feat-x"));
576 }
577
578 #[test]
581 fn context_inactive_when_off_or_non_openspec() {
582 let base = RoleGatingContext {
583 mode: RoleGatingMode::Warn,
584 engine_is_openspec: true,
585 roster: vec![],
586 };
587 assert!(base.is_active());
588 assert!(
589 !RoleGatingContext {
590 mode: RoleGatingMode::Off,
591 ..base.clone()
592 }
593 .is_active()
594 );
595 assert!(
596 !RoleGatingContext {
597 engine_is_openspec: false,
598 ..base
599 }
600 .is_active()
601 );
602 }
603
604 #[test]
605 fn touches_openspec_pre_filter() {
606 assert!(touches_openspec("openspec/changes/feat-x/tasks.md"));
607 assert!(touches_openspec("openspec/specs/cap/spec.md"));
608 assert!(!touches_openspec("src/main.rs"));
609 }
610}