Skip to main content

git_paw/opsx/
role_guard.rs

1//! Post-commit role-gating guard for the `OpenSpec` spec engine.
2//!
3//! When a worktree commit lands, the broker runs this guard against the
4//! `agent.artifact { status: "committed" }` event. The guard:
5//!
6//! 1. fast-fails unless the commit touched `openspec/changes/` or
7//!    `openspec/specs/` (cheap pre-filter before the heavier heuristic);
8//! 2. classifies the commit as archive activity via [`classify_commit`]
9//!    (commit-message match OR diff-shape signal);
10//! 3. attributes the commit to an agent via [`resolve_agent_id`]
11//!    (`"supervisor"` is the only non-violating role);
12//! 4. publishes feedback / learning per the configured
13//!    [`RoleGatingMode`].
14//!
15//! The detection heuristic is deliberately conservative — a false positive
16//! on the supervisor's own archive is cleared by the attribution check
17//! (step 3), and the warning text names the trigger so the user can spot
18//! a genuine false positive at a glance. See the change's `design.md` D1–D7.
19
20use 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
31/// The agent id that owns verification and archival. Any other id committing
32/// archive activity is a role-gating violation. Established by the v0.5.0
33/// supervisor-as-pane work.
34pub const SUPERVISOR_AGENT_ID: &str = "supervisor";
35
36/// The `from` / sender label stamped on guard-published messages so the user
37/// (and the offending agent's LLM) can see the warning came from the guard.
38pub const ROLE_GUARD_SENDER: &str = "opsx-role-gating";
39
40/// Result of classifying a commit against the archive-activity heuristic.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Classification {
43    /// The commit resembles an archive operation. `reason` names the signal(s)
44    /// that triggered classification (for diagnosability — D7).
45    Archive {
46        /// Human-readable explanation of which signal(s) fired, including the
47        /// matched message subject and/or detected path.
48        reason: String,
49    },
50    /// The commit does not resemble an archive operation.
51    NotArchive,
52}
53
54/// The changed-path view of a commit's diff used by [`classify_commit`].
55///
56/// Built from the `agent.artifact` payload's `modified_files` (which the
57/// post-commit hook derives from `git diff HEAD~1 --name-only`), so the
58/// archive-destination and main-spec paths appear without an extra git read.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct CommitDiff {
61    /// Paths touched by the commit (added, modified, and rename destinations).
62    pub touched_paths: Vec<String>,
63}
64
65impl CommitDiff {
66    /// Builds a [`CommitDiff`] from a slice of changed paths.
67    #[must_use]
68    pub fn from_paths(paths: &[String]) -> Self {
69        Self {
70            touched_paths: paths.to_vec(),
71        }
72    }
73}
74
75/// Attribution of a commit to the role that produced it.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum AgentAttribution {
78    /// The supervisor (`agent_id == "supervisor"`). Never a violation.
79    Supervisor,
80    /// A coding agent, named by its `agent_id`.
81    Coding(String),
82    /// The worktree could not be resolved to a session agent. Treated as a
83    /// violation (conservative default — D2).
84    Unknown,
85}
86
87impl AgentAttribution {
88    /// Whether this attribution counts as a role-gating violation.
89    ///
90    /// Everything except [`Self::Supervisor`] is a violation — including
91    /// [`Self::Unknown`], per the conservative default.
92    #[must_use]
93    pub fn is_violation(&self) -> bool {
94        !matches!(self, AgentAttribution::Supervisor)
95    }
96}
97
98/// Per-session role-gating context threaded into the broker state.
99///
100/// Built at broker start from the resolved spec engine, the configured mode,
101/// and the session's worktree roster (each coding agent's
102/// `agent_id -> worktree_path`, plus `("supervisor", repo_root)`).
103#[derive(Debug, Clone)]
104pub struct RoleGatingContext {
105    /// The configured enforcement mode.
106    pub mode: RoleGatingMode,
107    /// Whether the session's resolved spec engine is `OpenSpec`. The guard is
108    /// inert when this is `false`.
109    pub engine_is_openspec: bool,
110    /// `(agent_id, worktree_path)` pairs for every committing role, including
111    /// the supervisor mapped to the repo root.
112    pub roster: Vec<(String, PathBuf)>,
113}
114
115impl RoleGatingContext {
116    /// Returns the worktree path registered for `agent_id`, if any.
117    #[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    /// Whether the guard should run at all (active mode + `OpenSpec` engine).
126    #[must_use]
127    pub fn is_active(&self) -> bool {
128        self.engine_is_openspec && self.mode != RoleGatingMode::Off
129    }
130}
131
132/// The canonical archive commit-message pattern (D1 signal 1): the convention
133/// emitted by the v0.5.0+ release/archive procedure.
134fn 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
142/// Matches a path that moves a change into `openspec/changes/archive/<name>/`
143/// (D1 signal 2, archive-move half).
144fn 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
151/// Matches an addition/update to a main spec `openspec/specs/<capability>/spec.md`
152/// (D1 signal 2, deltas-merged half).
153fn 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/// Whether a path touches the `OpenSpec` change/spec trees — the cheap
161/// pre-filter that gates the heavier heuristic (task 5.3).
162#[must_use]
163pub fn touches_openspec(path: &str) -> bool {
164    path.contains("openspec/changes/") || path.contains("openspec/specs/")
165}
166
167/// Classifies a commit as archive activity or not.
168///
169/// A commit is archive activity when EITHER the commit message's subject line
170/// matches the canonical archive pattern OR the diff moves files into
171/// `openspec/changes/archive/<name>/` and/or adds/updates a main spec under
172/// `openspec/specs/<capability>/spec.md`. The returned `reason` names every
173/// signal that fired (including the matched subject and/or detected path) so
174/// the user can diagnose a false positive.
175#[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/// Resolves the committing role for `worktree` against the session roster.
214///
215/// The roster is `(agent_id, worktree_path)` pairs. A worktree matching the
216/// supervisor's entry resolves to [`AgentAttribution::Supervisor`]; any other
217/// match resolves to [`AgentAttribution::Coding`]; an unmatched worktree
218/// resolves to [`AgentAttribution::Unknown`] (treated as a violation).
219#[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
233/// Path equality that tolerates symlinked roots (e.g. macOS `/var` ->
234/// `/private/var`) by falling back to canonicalisation. Direct equality is
235/// tried first so non-existent test paths still compare correctly.
236fn 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/// Builds the diagnosable warning text published to the offending agent (D7,
244/// task 6.4): short SHA + `agent_id` + trigger reason.
245#[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/// Builds the revert-request text published to the supervisor in `block` mode.
256#[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
267/// Runs the role-gating guard for one `agent.artifact { status: "committed" }`
268/// event. Called from [`crate::broker::delivery::publish_message`] after the
269/// write lock is released, mirroring the verify-on-commit nudge path.
270///
271/// No-ops unless the context is active (`OpenSpec` engine + non-`off` mode) and
272/// the commit both touches the `OpenSpec` trees and classifies as archive
273/// activity by a non-supervisor agent.
274pub 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    // Fast-fail before the heavier heuristic (task 5.3).
285    if !payload.modified_files.iter().any(|p| touches_openspec(p)) {
286        return;
287    }
288
289    // Resolve the committing worktree so we can read the commit message + SHA.
290    // An unresolved worktree leaves the SHA/message blank but still classifies
291    // from the diff shape (the modified-files list) — the conservative path.
292    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        // The supervisor's own archive trips the heuristic but clears here.
310        return;
311    }
312
313    // Warn-mode actions: feedback to the violator + a permission_pattern
314    // learning (always performed for warn AND block).
315    publish_warn(state, agent_id, &short_sha, &reason);
316
317    // Block-mode adds a revert request routed to the supervisor.
318    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
333/// Publishes the warn-mode outputs: an `agent.feedback` to the violator and an
334/// `agent.learning` with category `permission_pattern`.
335fn 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        // Cross-cutting permission pattern → not branch-scoped (routes to the
352        // supervisor inbox + the learnings file the user reads).
353        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
366/// Best-effort read of `(short_sha, full_message)` for the worktree's `HEAD`
367/// commit. Returns `None` on any git failure (the caller then treats the SHA
368/// as `"unknown"` and classifies from the diff shape alone).
369fn 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    // --- classify_commit (task 3.5) ---
397
398    #[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        // Only the exact canonical shape matches; a stray "archive" mention
497        // (without the diff shape) is NotArchive.
498        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    // --- resolve_agent_id (task 4.3) ---
506
507    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    // --- diagnosable warning text (Diagnosable warning text requirement) ---
552
553    #[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    // --- context activation ---
579
580    #[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}